mpengine 0.2.0__tar.gz → 0.3.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.2.0/src/mpengine.egg-info → mpengine-0.3.0}/PKG-INFO +42 -2
- {mpengine-0.2.0 → mpengine-0.3.0}/README.md +41 -1
- {mpengine-0.2.0 → mpengine-0.3.0}/pyproject.toml +1 -1
- {mpengine-0.2.0 → mpengine-0.3.0}/src/mpengine/__init__.py +11 -1
- mpengine-0.3.0/src/mpengine/engine.py +258 -0
- {mpengine-0.2.0 → mpengine-0.3.0}/src/mpengine/orchestrator.py +281 -61
- {mpengine-0.2.0 → mpengine-0.3.0}/src/mpengine/partition.py +50 -1
- {mpengine-0.2.0 → mpengine-0.3.0/src/mpengine.egg-info}/PKG-INFO +42 -2
- mpengine-0.2.0/src/mpengine/engine.py +0 -152
- {mpengine-0.2.0 → mpengine-0.3.0}/LICENSE +0 -0
- {mpengine-0.2.0 → mpengine-0.3.0}/setup.cfg +0 -0
- {mpengine-0.2.0 → mpengine-0.3.0}/src/mpengine/banner.py +0 -0
- {mpengine-0.2.0 → mpengine-0.3.0}/src/mpengine/spider.txt +0 -0
- {mpengine-0.2.0 → mpengine-0.3.0}/src/mpengine/worker_names.json +0 -0
- {mpengine-0.2.0 → mpengine-0.3.0}/src/mpengine.egg-info/SOURCES.txt +0 -0
- {mpengine-0.2.0 → mpengine-0.3.0}/src/mpengine.egg-info/dependency_links.txt +0 -0
- {mpengine-0.2.0 → mpengine-0.3.0}/src/mpengine.egg-info/requires.txt +0 -0
- {mpengine-0.2.0 → mpengine-0.3.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
|
+
Version: 0.3.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
|
|
@@ -42,6 +42,7 @@ Every run produces three things:
|
|
|
42
42
|
| a manifest of exactly what was launched, and how it ended | `<base>/manifests/<run_id>.txt` |
|
|
43
43
|
| each job's result, saved to disk | `<base>/outputs/<run_id>/<label>` |
|
|
44
44
|
| one log file per **worker process** | `<base>/logs/<run_id>/worker_<pid>.log` |
|
|
45
|
+
| a log of the run as a whole | `<base>/logs/<run_id>/run.log` |
|
|
45
46
|
|
|
46
47
|
A single job failing does **not** abort the run — it is recorded as a failed
|
|
47
48
|
job and the rest continue.
|
|
@@ -127,9 +128,43 @@ run with a custom `save_fn`, pass the matching reader as
|
|
|
127
128
|
| `show_progress` | live terminal display: one overall bar for the whole run, plus a live rate number per worker process. Ignored when `debug=True` |
|
|
128
129
|
|
|
129
130
|
Develop with `debug=True`, then flip it off. Chasing a bug through a process
|
|
130
|
-
pool means reading a
|
|
131
|
+
pool means reading a traceback re-raised from a worker that has already exited,
|
|
131
132
|
and it never tells you which job dict was at fault.
|
|
132
133
|
|
|
134
|
+
### Logging
|
|
135
|
+
|
|
136
|
+
The library logs its whole lifecycle through the standard `logging` module and
|
|
137
|
+
writes nothing to stdout except the banner, so embedding it never pollutes a
|
|
138
|
+
host application's output. It installs only a `NullHandler` — to see anything,
|
|
139
|
+
configure logging yourself:
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
import logging
|
|
143
|
+
logging.basicConfig(level=logging.INFO)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Two loggers, so either half can be tuned or silenced independently:
|
|
147
|
+
|
|
148
|
+
| logger | emits |
|
|
149
|
+
|---|---|
|
|
150
|
+
| `mpengine.orchestrator` | run start/done, resolved dirs, per-job outcomes, worker ranking |
|
|
151
|
+
| `mpengine.engine` | dispatch start/done, worker-count clamping, progress milestones, worker death |
|
|
152
|
+
|
|
153
|
+
Levels: **INFO** for run lifecycle, **DEBUG** for per-job success (a 10,000-job
|
|
154
|
+
sweep would otherwise be 10,000 INFO lines), **WARNING** for a failed job or a
|
|
155
|
+
clamped worker count, **ERROR** for a dead worker.
|
|
156
|
+
|
|
157
|
+
Every run also writes `<log_dir>/<run_id>/run.log` containing the parent's
|
|
158
|
+
whole view of that run — dispatch, milestones, every job outcome, the summary —
|
|
159
|
+
alongside the existing per-worker log files. That happens regardless of how you
|
|
160
|
+
configure logging, so a run is always a self-contained durable record.
|
|
161
|
+
|
|
162
|
+
**Progress display is terminal-aware.** Interactively you get the tqdm bars and
|
|
163
|
+
per-worker lines as before. When stderr is not a terminal — piped, redirected to
|
|
164
|
+
a log file, running under CI or a scheduler — those are suppressed (they render
|
|
165
|
+
as unreadable escape-sequence noise in a file) and periodic completion
|
|
166
|
+
milestones are logged at INFO instead.
|
|
167
|
+
|
|
133
168
|
### Closures and lambdas
|
|
134
169
|
|
|
135
170
|
`func`, and any custom `save_fn`, can be a closure or a lambda — not just a
|
|
@@ -168,6 +203,11 @@ for triangular workloads — where item `i` costs `O(i)`, such as an
|
|
|
168
203
|
expanding-window computation — which keeps workers from idling while one
|
|
169
204
|
overloaded worker finishes.
|
|
170
205
|
|
|
206
|
+
## Changelog
|
|
207
|
+
|
|
208
|
+
See [CHANGELOG.md](CHANGELOG.md). Versions below 1.0 may carry breaking changes
|
|
209
|
+
in a minor release; each is listed there with the migration needed.
|
|
210
|
+
|
|
171
211
|
## What else is in the repo
|
|
172
212
|
|
|
173
213
|
The `src/learning/` package holds demo and exercise scripts used while
|
|
@@ -13,6 +13,7 @@ Every run produces three things:
|
|
|
13
13
|
| a manifest of exactly what was launched, and how it ended | `<base>/manifests/<run_id>.txt` |
|
|
14
14
|
| each job's result, saved to disk | `<base>/outputs/<run_id>/<label>` |
|
|
15
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` |
|
|
16
17
|
|
|
17
18
|
A single job failing does **not** abort the run — it is recorded as a failed
|
|
18
19
|
job and the rest continue.
|
|
@@ -98,9 +99,43 @@ run with a custom `save_fn`, pass the matching reader as
|
|
|
98
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` |
|
|
99
100
|
|
|
100
101
|
Develop with `debug=True`, then flip it off. Chasing a bug through a process
|
|
101
|
-
pool means reading a
|
|
102
|
+
pool means reading a traceback re-raised from a worker that has already exited,
|
|
102
103
|
and it never tells you which job dict was at fault.
|
|
103
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
|
+
|
|
104
139
|
### Closures and lambdas
|
|
105
140
|
|
|
106
141
|
`func`, and any custom `save_fn`, can be a closure or a lambda — not just a
|
|
@@ -139,6 +174,11 @@ for triangular workloads — where item `i` costs `O(i)`, such as an
|
|
|
139
174
|
expanding-window computation — which keeps workers from idling while one
|
|
140
175
|
overloaded worker finishes.
|
|
141
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
|
+
|
|
142
182
|
## What else is in the repo
|
|
143
183
|
|
|
144
184
|
The `src/learning/` package holds demo and exercise scripts used while
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "mpengine"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
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
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -39,6 +39,16 @@ Learning*, Ch.20 - the docstrings name the specific snippets - but nothing here
|
|
|
39
39
|
is finance-specific; it parallelizes any callable.
|
|
40
40
|
"""
|
|
41
41
|
|
|
42
|
+
import logging as _logging
|
|
43
|
+
|
|
44
|
+
# A library must never configure logging for its host application - it only
|
|
45
|
+
# names its loggers and attaches a no-op handler so that a caller who has set
|
|
46
|
+
# nothing up sees no "No handlers could be found" noise. To actually see any
|
|
47
|
+
# of it: logging.basicConfig(level=logging.INFO). Loggers are
|
|
48
|
+
# "mpengine.engine" and "mpengine.orchestrator", so either half can be tuned
|
|
49
|
+
# or silenced on its own.
|
|
50
|
+
_logging.getLogger("mpengine").addHandler(_logging.NullHandler())
|
|
51
|
+
|
|
42
52
|
from mpengine.engine import expand_call, process_jobs, process_jobs_, report_progress
|
|
43
53
|
from mpengine.orchestrator import (
|
|
44
54
|
JobResult,
|
|
@@ -73,4 +83,4 @@ __all__ = [
|
|
|
73
83
|
"parts_to_molecules",
|
|
74
84
|
]
|
|
75
85
|
|
|
76
|
-
__version__ = "0.
|
|
86
|
+
__version__ = "0.3.0"
|
|
@@ -0,0 +1,258 @@
|
|
|
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 logging
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
from concurrent.futures import ProcessPoolExecutor, as_completed
|
|
22
|
+
from concurrent.futures.process import BrokenProcessPool
|
|
23
|
+
from typing import Any, Callable
|
|
24
|
+
|
|
25
|
+
import cloudpickle
|
|
26
|
+
|
|
27
|
+
_log = logging.getLogger("mpengine.engine")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def expand_call(kargs: dict[str, Any]) -> Any:
|
|
31
|
+
"""Ch.20 Snippet 20.10 (`expandCall`) - turn a job dict into a call.
|
|
32
|
+
|
|
33
|
+
Deviation from the book: `kargs` is copied before popping. The book's
|
|
34
|
+
literal `kargs.pop('func')` mutates the *caller's* dict in place, so the
|
|
35
|
+
same job list can only ever be run once - a second pass raises
|
|
36
|
+
KeyError('func'). ex04 deliberately runs one job list through both
|
|
37
|
+
`process_jobs_` and `process_jobs` to compare them, so the copy matters.
|
|
38
|
+
"""
|
|
39
|
+
kargs = dict(kargs)
|
|
40
|
+
func = kargs.pop("func")
|
|
41
|
+
return func(**kargs)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def process_jobs_(jobs: list[dict[str, Any]]) -> list[Any]:
|
|
45
|
+
"""Ch.20 Snippet 20.8 (`processJobs_`) - sequential fallback, for debugging.
|
|
46
|
+
|
|
47
|
+
Runs every job in-process, one at a time, in submission order. No pool,
|
|
48
|
+
no pickling, no spawn - so a failing job raises immediately, at its exact
|
|
49
|
+
position in the list, with an ordinary live traceback you can attach a
|
|
50
|
+
debugger to. That clarity is the entire reason this mode exists.
|
|
51
|
+
"""
|
|
52
|
+
return [expand_call(job) for job in jobs]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def report_progress(job_num: int, num_jobs: int, time0: float, task: str) -> None:
|
|
56
|
+
"""Ch.20 Snippet 20.9's `reportProgress`, modernized.
|
|
57
|
+
|
|
58
|
+
Overwrites its own line via '\\r' until the final job, then emits '\\n'.
|
|
59
|
+
Units stay the book's minutes even though a fast demo run always shows
|
|
60
|
+
"0.00 minutes" - the snippet is sized for real multi-minute workloads.
|
|
61
|
+
"""
|
|
62
|
+
frac = job_num / num_jobs
|
|
63
|
+
minutes_elapsed = (time.time() - time0) / 60.0
|
|
64
|
+
minutes_remaining = minutes_elapsed * (1 / frac - 1) if frac > 0 else 0.0
|
|
65
|
+
timestamp = dt.datetime.now().isoformat(sep=" ", timespec="seconds")
|
|
66
|
+
msg = (
|
|
67
|
+
f"{timestamp} {frac * 100:5.1f}% {task} done after {minutes_elapsed:.2f} "
|
|
68
|
+
f"minutes. Remaining {minutes_remaining:.2f} minutes."
|
|
69
|
+
)
|
|
70
|
+
print(msg, end="\n" if job_num >= num_jobs else "\r", file=sys.stderr, flush=True)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _run_from_blob(blob: bytes) -> tuple[int, float, Any]:
|
|
74
|
+
"""Executor target: undo the cloudpickle wrapping `process_jobs` applies
|
|
75
|
+
before submission, then dispatch exactly as `expand_call` always has.
|
|
76
|
+
|
|
77
|
+
Returns `(pid, atom_seconds, result)`. The pid attributes the completion to
|
|
78
|
+
a specific worker process; `atom_seconds` is measured around the call
|
|
79
|
+
itself, *inside* the worker, so it is pure compute time - it cannot include
|
|
80
|
+
queueing, spawn cost, or the idle gap between this atom finishing and the
|
|
81
|
+
rest of the run finishing. That distinction is the whole point: timing the
|
|
82
|
+
same thing from the parent would measure when the result *arrived*, not how
|
|
83
|
+
long the work actually took.
|
|
84
|
+
"""
|
|
85
|
+
job = cloudpickle.loads(blob)
|
|
86
|
+
t0 = time.perf_counter()
|
|
87
|
+
result = expand_call(job)
|
|
88
|
+
return os.getpid(), time.perf_counter() - t0, result
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _infer_task_name(jobs: list[dict[str, Any]]) -> str:
|
|
92
|
+
"""Best-effort display name for a job list.
|
|
93
|
+
|
|
94
|
+
The obvious `jobs[0]["func"].__name__` crashes with AttributeError on the
|
|
95
|
+
very callables this engine goes out of its way to support: a
|
|
96
|
+
`functools.partial` (a natural way to pin one big fitted object once) and
|
|
97
|
+
any class instance implementing `__call__` have no `__name__`. Degrade to
|
|
98
|
+
the type name, then to a constant, rather than refusing to run.
|
|
99
|
+
"""
|
|
100
|
+
func = jobs[0].get("func")
|
|
101
|
+
return getattr(func, "__name__", None) or type(func).__name__ or "job"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def process_jobs(
|
|
105
|
+
jobs: list[dict[str, Any]],
|
|
106
|
+
task: str | None = None,
|
|
107
|
+
n_workers: int = os.cpu_count() or 4,
|
|
108
|
+
on_progress: Callable[[int, float, Any], None] | None = None,
|
|
109
|
+
on_job_error: Callable[[int, BaseException], None] | None = None,
|
|
110
|
+
text_progress: bool | None = None,
|
|
111
|
+
) -> list[Any]:
|
|
112
|
+
"""Ch.20 Snippet 20.9 (`processJobs`) - parallel dispatch over a process pool.
|
|
113
|
+
|
|
114
|
+
Named `n_workers`, not `n_threads`: this spawns OS *processes*, each with
|
|
115
|
+
its own interpreter and memory space, not threads sharing one. That
|
|
116
|
+
distinction matters here specifically - ex02 measured that CPython's GIL
|
|
117
|
+
makes threads add nothing for CPU-bound work, which is exactly what this
|
|
118
|
+
engine dispatches.
|
|
119
|
+
|
|
120
|
+
Results arrive in *completion* order, not submission order, which is what
|
|
121
|
+
lets `report_progress` report honestly as each job lands - with uneven job
|
|
122
|
+
costs (see ex03), an early-submitted heavy job can finish long after
|
|
123
|
+
several later-submitted light ones. (`orchestrator.run` re-sorts back to
|
|
124
|
+
submission order before returning, since its results are label-addressed.)
|
|
125
|
+
|
|
126
|
+
Each job is serialized with `cloudpickle` before submission (rather than
|
|
127
|
+
relying on the executor's own stdlib-`pickle` handling), so a job's 'func'
|
|
128
|
+
- or a nested callable, like `orchestrator.run`'s `save_fn` - can be a
|
|
129
|
+
closure or a lambda, not just a module-level function. `_run_from_blob` is
|
|
130
|
+
itself a plain module-level function, so it still pickles by reference with
|
|
131
|
+
no dependence on `__main__` spawn fixup.
|
|
132
|
+
|
|
133
|
+
Deliberately built on `concurrent.futures.ProcessPoolExecutor` rather than
|
|
134
|
+
the book's literal `mp.Pool` + `imap_unordered`. `mp.Pool` cannot detect a
|
|
135
|
+
worker that DIES mid-job (OOM-killed, or segfaulting inside native code
|
|
136
|
+
such as BLAS): CPython only resolves a task slot when a result arrives on
|
|
137
|
+
the output queue, and a dead process posts nothing, so the run blocks
|
|
138
|
+
forever with no exception and no log line. `ProcessPoolExecutor` watches
|
|
139
|
+
its workers and raises `BrokenProcessPool` instead - a hang that never
|
|
140
|
+
surfaces is far worse for an unattended run than a loud failure. The
|
|
141
|
+
`with` block here is also genuinely graceful, unlike `mp.Pool.__exit__`,
|
|
142
|
+
which calls `terminate()`.
|
|
143
|
+
|
|
144
|
+
`on_progress`, if given, is called as `on_progress(pid, atom_seconds,
|
|
145
|
+
result)` for every completed job - `pid` identifies which worker process
|
|
146
|
+
produced it and `atom_seconds` is that job's pure compute time as measured
|
|
147
|
+
inside the worker, which is what lets a caller (see `orchestrator.run`'s
|
|
148
|
+
`show_progress`) drive a per-worker live display with real timings. The
|
|
149
|
+
return value here is unaffected either way - still the plain `list[Any]`
|
|
150
|
+
of results, never the `(pid, atom_seconds, result)` triples.
|
|
151
|
+
`text_progress` controls the book's own `\\r`-overwriting stderr line.
|
|
152
|
+
Left at None it is automatic: emitted only when no `on_progress` display
|
|
153
|
+
is running AND stderr is a real terminal. That distinction matters in
|
|
154
|
+
production - redirected to a log file, a `\\r` line per job is unreadable
|
|
155
|
+
noise, so when the text display is off this logs periodic completion
|
|
156
|
+
milestones at INFO instead. Pass True/False to force it either way.
|
|
157
|
+
|
|
158
|
+
`on_job_error`, if given, is called as `on_job_error(index, exc)` for any
|
|
159
|
+
job that cannot be *serialized* for submission, and that job is skipped
|
|
160
|
+
instead of sinking the batch. Jobs are cloudpickled up front, so without
|
|
161
|
+
this one unpicklable payload (a lock, a live socket, an open file handle
|
|
162
|
+
captured by a closure) raises before any job runs at all - job 517 of 1000
|
|
163
|
+
taking down the 999 that were perfectly runnable. Left as None, that
|
|
164
|
+
original fail-fast behaviour is preserved, which keeps this layer
|
|
165
|
+
book-faithful; `orchestrator.run` opts in to turn such a failure into one
|
|
166
|
+
failed job, matching the per-job isolation it already promises.
|
|
167
|
+
"""
|
|
168
|
+
if not jobs:
|
|
169
|
+
return []
|
|
170
|
+
if task is None:
|
|
171
|
+
task = _infer_task_name(jobs)
|
|
172
|
+
|
|
173
|
+
# Serialize per job rather than in one comprehension, so a single
|
|
174
|
+
# unpicklable payload can be attributed and skipped instead of aborting
|
|
175
|
+
# the whole submission (see `on_job_error`).
|
|
176
|
+
blobs: list[bytes] = []
|
|
177
|
+
for i, job in enumerate(jobs):
|
|
178
|
+
try:
|
|
179
|
+
blobs.append(cloudpickle.dumps(job))
|
|
180
|
+
except Exception as exc:
|
|
181
|
+
if on_job_error is None:
|
|
182
|
+
raise
|
|
183
|
+
_log.warning(
|
|
184
|
+
"job could not be serialized for dispatch, skipping "
|
|
185
|
+
"task=%s job_index=%d error=%s", task, i, exc,
|
|
186
|
+
)
|
|
187
|
+
on_job_error(i, exc)
|
|
188
|
+
if not blobs:
|
|
189
|
+
_log.warning("nothing dispatchable task=%s n_jobs=%d", task, len(jobs))
|
|
190
|
+
return []
|
|
191
|
+
|
|
192
|
+
# Never spin up more workers than there are jobs to hand them: the
|
|
193
|
+
# executor starts its processes eagerly, so surplus workers pay full spawn
|
|
194
|
+
# cost to do nothing. This only ever clamps downward - when jobs outnumber
|
|
195
|
+
# n_workers no clamp is needed, since the same fixed pool keeps pulling
|
|
196
|
+
# jobs until the list is done. Worker count is sized to hardware, not to
|
|
197
|
+
# job count.
|
|
198
|
+
out: list[Any] = []
|
|
199
|
+
time0 = time.time()
|
|
200
|
+
n_submitted = len(blobs)
|
|
201
|
+
n_pool = min(n_workers, n_submitted)
|
|
202
|
+
if n_pool < n_workers:
|
|
203
|
+
_log.warning(
|
|
204
|
+
"n_workers=%d clamped to %d task=%s reason=fewer jobs than workers",
|
|
205
|
+
n_workers, n_pool, task,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Auto: the book's carriage-return line is a terminal affordance, so only emit it when
|
|
209
|
+
# a terminal is actually there and nothing else owns the display.
|
|
210
|
+
if text_progress is None:
|
|
211
|
+
text_progress = on_progress is None and sys.stderr.isatty()
|
|
212
|
+
# When nothing is drawing live, log milestones so a redirected run still
|
|
213
|
+
# shows movement - roughly ten lines, whatever the job count.
|
|
214
|
+
milestone = max(1, n_submitted // 10) if not text_progress else 0
|
|
215
|
+
|
|
216
|
+
_log.info(
|
|
217
|
+
"dispatch start task=%s n_jobs=%d n_workers=%d text_progress=%s",
|
|
218
|
+
task, n_submitted, n_pool, text_progress,
|
|
219
|
+
)
|
|
220
|
+
with ProcessPoolExecutor(max_workers=n_pool) as executor:
|
|
221
|
+
futures = [executor.submit(_run_from_blob, blob) for blob in blobs]
|
|
222
|
+
try:
|
|
223
|
+
for i, future in enumerate(as_completed(futures), 1):
|
|
224
|
+
pid, atom_s, out_ = future.result()
|
|
225
|
+
out.append(out_)
|
|
226
|
+
if on_progress is not None:
|
|
227
|
+
on_progress(pid, atom_s, out_)
|
|
228
|
+
if text_progress:
|
|
229
|
+
report_progress(i, n_submitted, time0, task)
|
|
230
|
+
elif milestone and (i % milestone == 0 or i == n_submitted):
|
|
231
|
+
_log.info(
|
|
232
|
+
"progress task=%s done=%d/%d pct=%.0f elapsed_s=%.2f",
|
|
233
|
+
task, i, n_submitted, 100.0 * i / n_submitted,
|
|
234
|
+
time.time() - time0,
|
|
235
|
+
)
|
|
236
|
+
except BrokenProcessPool as exc:
|
|
237
|
+
# Cancel whatever has not started so shutdown does not block on
|
|
238
|
+
# work that can never complete, then re-raise with the context the
|
|
239
|
+
# bare stdlib error lacks: how far the run actually got.
|
|
240
|
+
for future in futures:
|
|
241
|
+
future.cancel()
|
|
242
|
+
_log.error(
|
|
243
|
+
"worker process died task=%s completed=%d/%d - run aborted",
|
|
244
|
+
task, len(out), n_submitted,
|
|
245
|
+
)
|
|
246
|
+
raise BrokenProcessPool(
|
|
247
|
+
f"a worker process died during task {task!r} after "
|
|
248
|
+
f"{len(out)}/{n_submitted} jobs completed - it was most likely "
|
|
249
|
+
f"OOM-killed or crashed inside native code (e.g. BLAS). The "
|
|
250
|
+
f"completed jobs' results were lost with the pool; if you need "
|
|
251
|
+
f"per-job durability use orchestrator.run, which saves each "
|
|
252
|
+
f"result to disk as it lands."
|
|
253
|
+
) from exc
|
|
254
|
+
_log.info(
|
|
255
|
+
"dispatch done task=%s completed=%d/%d elapsed_s=%.2f",
|
|
256
|
+
task, len(out), n_submitted, time.time() - time0,
|
|
257
|
+
)
|
|
258
|
+
return out
|
|
@@ -42,6 +42,13 @@ from mpengine.engine import expand_call, process_jobs, process_jobs_
|
|
|
42
42
|
|
|
43
43
|
N_WORKERS = os.cpu_count() or 4
|
|
44
44
|
|
|
45
|
+
# Run reporting goes through logging rather than print, so embedding mpengine
|
|
46
|
+
# in a larger app or a CI job does not pollute stdout. The library adds no
|
|
47
|
+
# handler of its own (a library must not configure the root logger); callers
|
|
48
|
+
# who want to see this call logging.basicConfig(level=logging.INFO). The
|
|
49
|
+
# spider banner is the one deliberate exception and still prints.
|
|
50
|
+
_log = logging.getLogger("mpengine.orchestrator")
|
|
51
|
+
|
|
45
52
|
# one FileHandler-backed logger per worker *process*, lazily created on that
|
|
46
53
|
# process's first job and reused for every job after - keyed by pid, which is
|
|
47
54
|
# always fresh in a newly spawned worker, so no cross-run coordination needed
|
|
@@ -111,7 +118,98 @@ def load_run_outputs(
|
|
|
111
118
|
path = Path(run_dir)
|
|
112
119
|
if not path.is_dir():
|
|
113
120
|
raise NotADirectoryError(f"not a run output directory: {path}")
|
|
114
|
-
|
|
121
|
+
# Skip `.<label>.partial` leftovers: those are in-flight or abandoned
|
|
122
|
+
# writes from `_run_and_save_job`, never a finished result.
|
|
123
|
+
return {
|
|
124
|
+
p.name: load_fn(p)
|
|
125
|
+
for p in sorted(path.iterdir())
|
|
126
|
+
if p.is_file() and not (p.name.startswith(".") and p.name.endswith(".partial"))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@contextmanager
|
|
131
|
+
def _run_log_file(log_path: Path) -> Iterator[None]:
|
|
132
|
+
"""Capture the parent's whole view of one run into `<log_dir>/run.log`.
|
|
133
|
+
|
|
134
|
+
The per-worker files record what each worker did; this records what the
|
|
135
|
+
run as a whole did - dispatch, milestones, per-job outcomes, the summary -
|
|
136
|
+
so one run is one self-contained, durable record. That is the audit trail
|
|
137
|
+
this library is actually differentiated on.
|
|
138
|
+
|
|
139
|
+
The handler is attached to the shared "mpengine" parent logger, so records
|
|
140
|
+
from both `mpengine.engine` and `mpengine.orchestrator` land in it.
|
|
141
|
+
|
|
142
|
+
Level handling is deliberately restrained. A logger's level decides whether
|
|
143
|
+
a record is created at all, and that decision is shared by every handler
|
|
144
|
+
downstream - so forcing DEBUG here to enrich the file would also push
|
|
145
|
+
DEBUG records into the caller's own handlers (a per-job line for all
|
|
146
|
+
10,000 jobs appearing in someone's console who asked for INFO). Instead the
|
|
147
|
+
level is only lowered as far as INFO, and only when it was coarser than
|
|
148
|
+
that, so the file always captures the full lifecycle and every failure
|
|
149
|
+
without ever making the caller's output noisier than they configured. A
|
|
150
|
+
caller who genuinely wants per-job DEBUG detail in the file just sets DEBUG
|
|
151
|
+
themselves. Everything is restored on the way out, including if the run
|
|
152
|
+
raises.
|
|
153
|
+
"""
|
|
154
|
+
parent = logging.getLogger("mpengine")
|
|
155
|
+
handler = logging.FileHandler(log_path / "run.log", encoding="utf-8")
|
|
156
|
+
handler.setLevel(logging.DEBUG)
|
|
157
|
+
handler.setFormatter(
|
|
158
|
+
logging.Formatter("%(asctime)s %(levelname)-7s %(name)s: %(message)s")
|
|
159
|
+
)
|
|
160
|
+
previous_level = parent.level
|
|
161
|
+
if not parent.isEnabledFor(logging.INFO):
|
|
162
|
+
parent.setLevel(logging.INFO)
|
|
163
|
+
parent.addHandler(handler)
|
|
164
|
+
try:
|
|
165
|
+
yield
|
|
166
|
+
finally:
|
|
167
|
+
parent.removeHandler(handler)
|
|
168
|
+
handler.close()
|
|
169
|
+
parent.setLevel(previous_level)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _validate_labels(labels: list[str]) -> None:
|
|
173
|
+
"""Reject labels that are unsafe or ambiguous as filenames.
|
|
174
|
+
|
|
175
|
+
Each label is used directly as a filename (`output_dir / label`), and
|
|
176
|
+
pathlib's `/` is unforgiving about what that permits: an absolute label
|
|
177
|
+
discards the base directory entirely, `..` walks out of the run tree, and
|
|
178
|
+
an empty label collapses onto the run directory itself so `save_fn` is
|
|
179
|
+
handed a directory to write. None of that is reachable from hostile input
|
|
180
|
+
- a caller only ever passes its own labels - but all of it silently writes
|
|
181
|
+
somewhere other than the run directory, which is worth a loud error rather
|
|
182
|
+
than a debugging session.
|
|
183
|
+
|
|
184
|
+
Duplicates are rejected for a different reason: two jobs sharing a label
|
|
185
|
+
write to one path, both report `ok` with that same `output_path`, and
|
|
186
|
+
whichever finishes last silently wins. The other result is simply gone.
|
|
187
|
+
"""
|
|
188
|
+
bad: list[str] = []
|
|
189
|
+
for label in labels:
|
|
190
|
+
if not isinstance(label, str) or not label.strip():
|
|
191
|
+
bad.append(f"{label!r} (empty)")
|
|
192
|
+
elif Path(label).is_absolute() or (len(label) > 1 and label[1] == ":"):
|
|
193
|
+
bad.append(f"{label!r} (absolute path)")
|
|
194
|
+
elif "/" in label or "\\" in label:
|
|
195
|
+
bad.append(f"{label!r} (contains a path separator)")
|
|
196
|
+
elif ".." in Path(label).parts:
|
|
197
|
+
bad.append(f"{label!r} (contains '..')")
|
|
198
|
+
if bad:
|
|
199
|
+
raise ValueError(
|
|
200
|
+
"labels are used directly as output filenames, so these are not usable: "
|
|
201
|
+
+ ", ".join(bad)
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
seen: dict[str, int] = {}
|
|
205
|
+
for label in labels:
|
|
206
|
+
seen[label] = seen.get(label, 0) + 1
|
|
207
|
+
dupes = sorted(lbl for lbl, n in seen.items() if n > 1)
|
|
208
|
+
if dupes:
|
|
209
|
+
raise ValueError(
|
|
210
|
+
"labels must be unique - each one names an output file, so duplicates "
|
|
211
|
+
f"would overwrite each other: {', '.join(repr(d) for d in dupes)}"
|
|
212
|
+
)
|
|
115
213
|
|
|
116
214
|
|
|
117
215
|
def _get_worker_logger(log_dir: Path) -> logging.Logger:
|
|
@@ -133,7 +231,7 @@ def _get_worker_logger(log_dir: Path) -> logging.Logger:
|
|
|
133
231
|
|
|
134
232
|
|
|
135
233
|
def _run_and_save_job(job: dict) -> JobResult:
|
|
136
|
-
"""The actual
|
|
234
|
+
"""The actual worker/sequential target. Unwraps its own bookkeeping fields,
|
|
137
235
|
calls the caller's function via `expand_call` (reusing engine.py rather
|
|
138
236
|
than reimplementing the dict-to-call trick), saves the result, and always
|
|
139
237
|
returns a JobResult - exceptions are caught here, never re-raised, so one
|
|
@@ -153,11 +251,24 @@ def _run_and_save_job(job: dict) -> JobResult:
|
|
|
153
251
|
logger.error("failed %s: %s\n%s", label, exc, traceback.format_exc())
|
|
154
252
|
return JobResult(label=label, status="error", error=str(exc))
|
|
155
253
|
|
|
254
|
+
# Write to a sibling temp path and only then rename into place. A save_fn
|
|
255
|
+
# that dies partway through (plausible for a large or custom serializer)
|
|
256
|
+
# would otherwise leave partial bytes at the real output path while the job
|
|
257
|
+
# is correctly recorded as failed - and a later `load_run_outputs`, which
|
|
258
|
+
# loads every file it finds, would then choke on that corpse and take down
|
|
259
|
+
# the read-back of an otherwise healthy run. os.replace is atomic on both
|
|
260
|
+
# POSIX and Windows, so the final path only ever holds a complete result.
|
|
156
261
|
output_path = output_dir / label
|
|
262
|
+
tmp_path = output_dir / f".{label}.partial"
|
|
157
263
|
try:
|
|
158
|
-
save_fn(result,
|
|
264
|
+
save_fn(result, tmp_path)
|
|
265
|
+
os.replace(tmp_path, output_path)
|
|
159
266
|
except Exception as exc:
|
|
160
267
|
logger.error("save failed %s: %s\n%s", label, exc, traceback.format_exc())
|
|
268
|
+
try:
|
|
269
|
+
tmp_path.unlink(missing_ok=True)
|
|
270
|
+
except OSError:
|
|
271
|
+
pass
|
|
161
272
|
return JobResult(label=label, status="error", error=f"save failed: {exc}")
|
|
162
273
|
|
|
163
274
|
logger.info("completed %s -> %s", label, output_path)
|
|
@@ -235,7 +346,11 @@ def _progress_renderer(
|
|
|
235
346
|
overall = tqdm(total=n_jobs, desc=task, position=0, file=sys.stderr, mininterval=0)
|
|
236
347
|
worker_bars: dict[int, tqdm] = {}
|
|
237
348
|
available_names = _WORKER_NAMES.copy()
|
|
238
|
-
random.shuffle
|
|
349
|
+
# A private Random instance, not `random.shuffle`. Shuffling via the module
|
|
350
|
+
# function advances the interpreter-global RNG, so picking cosmetic worker
|
|
351
|
+
# codenames would perturb any caller relying on `random` for a reproducible
|
|
352
|
+
# sequence - a real hazard for the Monte Carlo work this engine is aimed at.
|
|
353
|
+
random.Random().shuffle(available_names)
|
|
239
354
|
|
|
240
355
|
def _next_worker_name() -> str:
|
|
241
356
|
if available_names:
|
|
@@ -270,26 +385,34 @@ def _progress_renderer(
|
|
|
270
385
|
bar.close()
|
|
271
386
|
|
|
272
387
|
|
|
273
|
-
def
|
|
388
|
+
def _log_worker_ranking(stats: dict[int, WorkerStats]) -> None:
|
|
274
389
|
"""Rank workers fastest-to-slowest by seconds per atom.
|
|
275
390
|
|
|
276
|
-
|
|
277
|
-
|
|
391
|
+
Emitted through `logging`, not `print`: this is run reporting, and a
|
|
392
|
+
library that writes it unconditionally to stdout breaks piping, notebooks
|
|
393
|
+
and log aggregation for anything embedding mpengine. Callers who want to
|
|
394
|
+
see it configure logging (e.g. `logging.basicConfig(level=logging.INFO)`);
|
|
395
|
+
callers who do not are no longer forced to.
|
|
396
|
+
|
|
397
|
+
Note the caveat logged alongside: with uneven atom sizes - the normal case
|
|
398
|
+
in quant work - a low s/atom can mean small atoms rather than a genuinely
|
|
399
|
+
faster worker, so the fastest/slowest tags are a hint, not a measurement.
|
|
278
400
|
"""
|
|
279
401
|
if not stats:
|
|
280
402
|
return
|
|
281
403
|
|
|
282
404
|
ranked = sorted(stats.values(), key=lambda s: s.avg_atom_s)
|
|
283
|
-
|
|
405
|
+
lines = ["workers, fastest to slowest (by avg seconds per atom):"]
|
|
284
406
|
for i, s in enumerate(ranked):
|
|
285
407
|
tag = ""
|
|
286
408
|
if len(ranked) > 1:
|
|
287
409
|
tag = " <- fastest" if i == 0 else (" <- slowest" if i == len(ranked) - 1 else "")
|
|
288
|
-
|
|
410
|
+
lines.append(
|
|
289
411
|
f" {s.name:<12} (PID {s.pid:>6}) {s.n_atoms:>3} atoms "
|
|
290
412
|
f"avg {s.avg_atom_s:6.2f}s/atom {s.atoms_per_s:6.2f} atoms/s{tag}"
|
|
291
413
|
)
|
|
292
|
-
|
|
414
|
+
lines.append(" (uneven atom sizes: a low s/atom can mean small atoms, not a faster worker)")
|
|
415
|
+
_log.info("\n".join(lines))
|
|
293
416
|
|
|
294
417
|
|
|
295
418
|
def run(
|
|
@@ -353,8 +476,25 @@ def run(
|
|
|
353
476
|
f"pass base_dir, or all of output_dir/log_dir/manifest_dir (missing: {', '.join(missing)})"
|
|
354
477
|
)
|
|
355
478
|
|
|
356
|
-
|
|
357
|
-
|
|
479
|
+
# `func.__name__` is not safe: a functools.partial or a callable object -
|
|
480
|
+
# both of which this engine explicitly supports - has no __name__.
|
|
481
|
+
func_name = getattr(func, "__name__", None) or type(func).__name__ or "job"
|
|
482
|
+
task = task or func_name
|
|
483
|
+
|
|
484
|
+
# Everything above and below this point is validation; no directory,
|
|
485
|
+
# manifest or banner is produced until it all passes, so a rejected call
|
|
486
|
+
# cannot leave orphaned run artefacts behind.
|
|
487
|
+
labels = labels or [f"job_{i:04d}" for i in range(len(param_sets))]
|
|
488
|
+
if len(labels) != len(param_sets):
|
|
489
|
+
raise ValueError(f"got {len(labels)} labels for {len(param_sets)} param sets")
|
|
490
|
+
_validate_labels(labels)
|
|
491
|
+
|
|
492
|
+
# Microseconds, not seconds. At second resolution two runs of the same task
|
|
493
|
+
# inside one second produced an identical run_id, and because the
|
|
494
|
+
# directories are made with exist_ok=True they were silently *shared*: the
|
|
495
|
+
# second run's manifest, opened 'w', truncated the first run's manifest
|
|
496
|
+
# outright, and default job_NNNN labels overwrote its outputs.
|
|
497
|
+
run_id = f"{task}_{dt.datetime.now():%Y%m%d_%H%M%S_%f}"
|
|
358
498
|
|
|
359
499
|
output_path = Path(output_dir) / run_id
|
|
360
500
|
log_path = Path(log_dir) / run_id
|
|
@@ -363,10 +503,6 @@ def run(
|
|
|
363
503
|
Path(manifest_dir).mkdir(parents=True, exist_ok=True)
|
|
364
504
|
manifest_path = Path(manifest_dir) / f"{run_id}.txt"
|
|
365
505
|
|
|
366
|
-
labels = labels or [f"job_{i:04d}" for i in range(len(param_sets))]
|
|
367
|
-
if len(labels) != len(param_sets):
|
|
368
|
-
raise ValueError(f"got {len(labels)} labels for {len(param_sets)} param sets")
|
|
369
|
-
|
|
370
506
|
jobs = [
|
|
371
507
|
{
|
|
372
508
|
"func": _run_and_save_job,
|
|
@@ -384,7 +520,7 @@ def run(
|
|
|
384
520
|
with open(manifest_path, "w") as f:
|
|
385
521
|
f.write(f"run_id: {run_id}\n")
|
|
386
522
|
f.write(f"launched: {dt.datetime.now().isoformat(sep=' ', timespec='seconds')}\n")
|
|
387
|
-
f.write(f"func: {
|
|
523
|
+
f.write(f"func: {func_name}\n")
|
|
388
524
|
f.write(f"task: {task}\n")
|
|
389
525
|
f.write(f"n_workers: {n_workers}\n")
|
|
390
526
|
f.write(f"debug: {debug}\n")
|
|
@@ -396,50 +532,134 @@ def run(
|
|
|
396
532
|
f.write(f" {label}: {params}\n")
|
|
397
533
|
f.write("\n")
|
|
398
534
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
535
|
+
# One durable record per run: everything from here down - dispatch,
|
|
536
|
+
# milestones, every job outcome, the summary - is captured to
|
|
537
|
+
# <log_dir>/run.log as well as going to the caller's own handlers.
|
|
538
|
+
with _run_log_file(log_path):
|
|
539
|
+
worker_stats: dict[int, WorkerStats] = {}
|
|
540
|
+
|
|
541
|
+
print_banner(task, n_workers, debug)
|
|
542
|
+
_log.info(
|
|
543
|
+
"run start run_id=%s task=%s func=%s n_jobs=%d n_workers=%d "
|
|
544
|
+
"debug=%s show_progress=%s",
|
|
545
|
+
run_id, task, func_name, len(jobs), n_workers, debug, show_progress,
|
|
546
|
+
)
|
|
547
|
+
_log.info(
|
|
548
|
+
"run dirs run_id=%s output_dir=%s log_dir=%s manifest=%s",
|
|
549
|
+
run_id, output_path, log_path, manifest_path,
|
|
550
|
+
)
|
|
415
551
|
|
|
416
|
-
|
|
417
|
-
|
|
552
|
+
# A job that cannot even be cloudpickled for submission becomes one failed
|
|
553
|
+
# JobResult rather than sinking the whole batch - the same per-job
|
|
554
|
+
# isolation this layer already promises for jobs that fail while running.
|
|
555
|
+
unsendable: list[JobResult] = []
|
|
556
|
+
|
|
557
|
+
def on_job_error(index: int, exc: BaseException) -> None:
|
|
558
|
+
unsendable.append(
|
|
559
|
+
JobResult(
|
|
560
|
+
label=labels[index],
|
|
561
|
+
status="error",
|
|
562
|
+
error=f"could not be serialized for dispatch: {exc}",
|
|
563
|
+
)
|
|
564
|
+
)
|
|
418
565
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
566
|
+
def log_job(pid: int, atom_s: float, result: Any) -> None:
|
|
567
|
+
"""Surface every job outcome in the PARENT log as it lands.
|
|
568
|
+
|
|
569
|
+
Successes are DEBUG - a 10,000-job sweep should not write 10,000 INFO
|
|
570
|
+
lines - but failures are WARNING, because until now a failed job was
|
|
571
|
+
only ever written to that worker's own file and an operator watching
|
|
572
|
+
the run's logs saw nothing at all until the manifest was read.
|
|
573
|
+
"""
|
|
574
|
+
label = getattr(result, "label", "?")
|
|
575
|
+
if getattr(result, "status", None) == "ok":
|
|
576
|
+
_log.debug("job ok label=%s pid=%s atom_s=%.3f", label, pid, atom_s)
|
|
424
577
|
else:
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
578
|
+
_log.warning(
|
|
579
|
+
"job FAILED label=%s pid=%s atom_s=%.3f error=%s",
|
|
580
|
+
label, pid, atom_s, getattr(result, "error", None),
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
# The live display is a terminal affordance. Interactively it is the whole
|
|
584
|
+
# point; redirected to a log file tqdm's redraws are unreadable noise, so
|
|
585
|
+
# in prod we fall back to the engine's periodic INFO milestones instead.
|
|
586
|
+
interactive = sys.stderr.isatty()
|
|
587
|
+
live_display = show_progress and not debug and interactive
|
|
588
|
+
if show_progress and not debug and not interactive:
|
|
589
|
+
_log.info(
|
|
590
|
+
"progress display disabled run_id=%s reason=stderr is not a terminal "
|
|
591
|
+
"(logging milestones instead)", run_id,
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
t0 = time.perf_counter()
|
|
595
|
+
if debug:
|
|
596
|
+
raw_results = process_jobs_(jobs)
|
|
597
|
+
for r in raw_results:
|
|
598
|
+
log_job(os.getpid(), 0.0, r)
|
|
599
|
+
elif live_display:
|
|
600
|
+
with _progress_renderer(len(jobs), task, worker_stats) as render:
|
|
601
|
+
def on_progress(pid: int, atom_s: float, result: Any) -> None:
|
|
602
|
+
log_job(pid, atom_s, result)
|
|
603
|
+
render(pid, atom_s, result)
|
|
604
|
+
|
|
605
|
+
raw_results = process_jobs(
|
|
606
|
+
jobs, task=task, n_workers=n_workers,
|
|
607
|
+
on_progress=on_progress, on_job_error=on_job_error,
|
|
608
|
+
text_progress=False,
|
|
609
|
+
)
|
|
610
|
+
else:
|
|
611
|
+
raw_results = process_jobs(
|
|
612
|
+
jobs, task=task, n_workers=n_workers,
|
|
613
|
+
on_progress=log_job, on_job_error=on_job_error,
|
|
614
|
+
text_progress=(not show_progress) and interactive,
|
|
615
|
+
)
|
|
616
|
+
elapsed_s = time.perf_counter() - t0
|
|
617
|
+
|
|
618
|
+
if worker_stats:
|
|
619
|
+
_log_worker_ranking(worker_stats)
|
|
620
|
+
|
|
621
|
+
# Restore submission order. `process_jobs` yields in completion order (by
|
|
622
|
+
# design - that is what makes its progress reporting honest), but these
|
|
623
|
+
# results are label-addressed, so a caller zipping them against the
|
|
624
|
+
# param_sets it passed in would silently mismatch. Jobs that never made it
|
|
625
|
+
# off the ground are folded back into their original positions too.
|
|
626
|
+
raw_results = raw_results + unsendable
|
|
627
|
+
label_order = {label: i for i, label in enumerate(labels)}
|
|
628
|
+
raw_results.sort(key=lambda r: label_order.get(r.label, len(label_order)))
|
|
629
|
+
|
|
630
|
+
n_ok = sum(1 for r in raw_results if r.status == "ok")
|
|
631
|
+
n_failed = len(raw_results) - n_ok
|
|
632
|
+
|
|
633
|
+
with open(manifest_path, "a") as f:
|
|
634
|
+
f.write("results:\n")
|
|
635
|
+
for r in raw_results:
|
|
636
|
+
if r.status == "ok":
|
|
637
|
+
f.write(f" {r.label}: ok -> {r.output_path}\n")
|
|
638
|
+
else:
|
|
639
|
+
f.write(f" {r.label}: ERROR - {r.error}\n")
|
|
640
|
+
f.write(f"\nn_ok: {n_ok}\n")
|
|
641
|
+
f.write(f"n_failed: {n_failed}\n")
|
|
642
|
+
f.write(f"elapsed_s: {elapsed_s:.3f}\n")
|
|
643
|
+
|
|
644
|
+
_log.info(
|
|
645
|
+
"run done run_id=%s task=%s n_ok=%d n_failed=%d elapsed_s=%.2f "
|
|
646
|
+
"throughput_jobs_s=%.2f",
|
|
647
|
+
run_id, task, n_ok, n_failed, elapsed_s,
|
|
648
|
+
(len(raw_results) / elapsed_s) if elapsed_s > 0 else 0.0,
|
|
649
|
+
)
|
|
650
|
+
_log.info("Logs stored here - %s", log_path)
|
|
651
|
+
_log.info("Output stored here - %s", output_path)
|
|
652
|
+
_log.info("Manifest stored here - %s", manifest_path)
|
|
653
|
+
|
|
654
|
+
return RunSummary(
|
|
655
|
+
run_id=run_id,
|
|
656
|
+
manifest_path=str(manifest_path),
|
|
657
|
+
output_dir=str(output_path),
|
|
658
|
+
log_dir=str(log_path),
|
|
659
|
+
n_jobs=len(jobs),
|
|
660
|
+
n_ok=n_ok,
|
|
661
|
+
n_failed=n_failed,
|
|
662
|
+
elapsed_s=elapsed_s,
|
|
663
|
+
results=raw_results,
|
|
664
|
+
worker_stats=worker_stats,
|
|
665
|
+
)
|
|
@@ -39,6 +39,48 @@ def lin_parts(num_atoms: int, num_threads: int) -> np.ndarray:
|
|
|
39
39
|
return np.ceil(parts).astype(int)
|
|
40
40
|
|
|
41
41
|
|
|
42
|
+
def _integer_boundaries(float_parts: list[float], num_atoms: int, num_molecules: int) -> np.ndarray:
|
|
43
|
+
"""Turn exact float boundaries into integer ones without collapsing any
|
|
44
|
+
molecule to zero width.
|
|
45
|
+
|
|
46
|
+
Rounding each boundary independently (the obvious `np.round(parts)`) lets
|
|
47
|
+
two adjacent float boundaries land on the same integer, which yields an
|
|
48
|
+
empty molecule - i.e. a dispatched job with no work in it. That is not
|
|
49
|
+
rare: for `nested_parts` it happened for ~68% of (num_atoms, num_threads)
|
|
50
|
+
pairs under 80, including such ordinary cases as (10, 8).
|
|
51
|
+
|
|
52
|
+
Instead, allocate integer *widths* by largest remainder: floor each float
|
|
53
|
+
width, force a floor of 1 atom per molecule, then reconcile the total back
|
|
54
|
+
to `num_atoms` by handing surplus atoms to the largest fractional
|
|
55
|
+
remainders (or reclaiming from the smallest). A valid all-non-empty
|
|
56
|
+
assignment always exists here because callers clamp
|
|
57
|
+
`num_molecules <= num_atoms` first.
|
|
58
|
+
"""
|
|
59
|
+
widths = np.diff(np.asarray(float_parts, dtype=float))
|
|
60
|
+
base = np.floor(widths).astype(np.int64)
|
|
61
|
+
frac = widths - np.floor(widths)
|
|
62
|
+
base = np.maximum(base, 1)
|
|
63
|
+
|
|
64
|
+
shortfall = int(num_atoms) - int(base.sum())
|
|
65
|
+
if shortfall > 0:
|
|
66
|
+
order = np.argsort(-frac, kind="stable")
|
|
67
|
+
for k in range(shortfall):
|
|
68
|
+
base[order[k % num_molecules]] += 1
|
|
69
|
+
elif shortfall < 0:
|
|
70
|
+
order = np.argsort(frac, kind="stable")
|
|
71
|
+
k = 0
|
|
72
|
+
guard = 0
|
|
73
|
+
while shortfall < 0 and guard < 100 * num_molecules:
|
|
74
|
+
idx = order[k % num_molecules]
|
|
75
|
+
if base[idx] > 1:
|
|
76
|
+
base[idx] -= 1
|
|
77
|
+
shortfall += 1
|
|
78
|
+
k += 1
|
|
79
|
+
guard += 1
|
|
80
|
+
|
|
81
|
+
return np.concatenate(([0], np.cumsum(base))).astype(int)
|
|
82
|
+
|
|
83
|
+
|
|
42
84
|
def nested_parts(num_atoms: int, num_threads: int, upper_triang: bool = False) -> np.ndarray:
|
|
43
85
|
"""Ch.20 Snippet 20.6 (`nestedParts`) - boundary indices for up to
|
|
44
86
|
`num_threads` equal-*work* partitions of a triangular-cost workload where
|
|
@@ -52,15 +94,22 @@ def nested_parts(num_atoms: int, num_threads: int, upper_triang: bool = False) -
|
|
|
52
94
|
|
|
53
95
|
`upper_triang=True` reverses which end carries the heavy rows - use it
|
|
54
96
|
when atom cost *decreases* with atom index instead of increasing.
|
|
97
|
+
|
|
98
|
+
The float boundaries are solved exactly and converted to integers by
|
|
99
|
+
`_integer_boundaries`, which guarantees no molecule comes back empty -
|
|
100
|
+
rounding each boundary independently silently produced zero-width
|
|
101
|
+
molecules for the majority of input pairs.
|
|
55
102
|
"""
|
|
56
103
|
num_threads_ = min(num_threads, num_atoms)
|
|
104
|
+
if num_atoms <= 0 or num_threads_ <= 0:
|
|
105
|
+
return np.array([0], dtype=int)
|
|
57
106
|
parts = [0.0]
|
|
58
107
|
for _ in range(num_threads_):
|
|
59
108
|
prev = parts[-1]
|
|
60
109
|
part = 1 + 4 * (prev**2 + prev + num_atoms * (num_atoms + 1) / num_threads_)
|
|
61
110
|
part = (-1 + part**0.5) / 2
|
|
62
111
|
parts.append(part)
|
|
63
|
-
parts_arr =
|
|
112
|
+
parts_arr = _integer_boundaries(parts, num_atoms, num_threads_)
|
|
64
113
|
if upper_triang:
|
|
65
114
|
parts_arr = np.concatenate(([0], np.cumsum(np.diff(parts_arr)[::-1])))
|
|
66
115
|
return parts_arr
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mpengine
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.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
|
|
@@ -42,6 +42,7 @@ Every run produces three things:
|
|
|
42
42
|
| a manifest of exactly what was launched, and how it ended | `<base>/manifests/<run_id>.txt` |
|
|
43
43
|
| each job's result, saved to disk | `<base>/outputs/<run_id>/<label>` |
|
|
44
44
|
| one log file per **worker process** | `<base>/logs/<run_id>/worker_<pid>.log` |
|
|
45
|
+
| a log of the run as a whole | `<base>/logs/<run_id>/run.log` |
|
|
45
46
|
|
|
46
47
|
A single job failing does **not** abort the run — it is recorded as a failed
|
|
47
48
|
job and the rest continue.
|
|
@@ -127,9 +128,43 @@ run with a custom `save_fn`, pass the matching reader as
|
|
|
127
128
|
| `show_progress` | live terminal display: one overall bar for the whole run, plus a live rate number per worker process. Ignored when `debug=True` |
|
|
128
129
|
|
|
129
130
|
Develop with `debug=True`, then flip it off. Chasing a bug through a process
|
|
130
|
-
pool means reading a
|
|
131
|
+
pool means reading a traceback re-raised from a worker that has already exited,
|
|
131
132
|
and it never tells you which job dict was at fault.
|
|
132
133
|
|
|
134
|
+
### Logging
|
|
135
|
+
|
|
136
|
+
The library logs its whole lifecycle through the standard `logging` module and
|
|
137
|
+
writes nothing to stdout except the banner, so embedding it never pollutes a
|
|
138
|
+
host application's output. It installs only a `NullHandler` — to see anything,
|
|
139
|
+
configure logging yourself:
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
import logging
|
|
143
|
+
logging.basicConfig(level=logging.INFO)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Two loggers, so either half can be tuned or silenced independently:
|
|
147
|
+
|
|
148
|
+
| logger | emits |
|
|
149
|
+
|---|---|
|
|
150
|
+
| `mpengine.orchestrator` | run start/done, resolved dirs, per-job outcomes, worker ranking |
|
|
151
|
+
| `mpengine.engine` | dispatch start/done, worker-count clamping, progress milestones, worker death |
|
|
152
|
+
|
|
153
|
+
Levels: **INFO** for run lifecycle, **DEBUG** for per-job success (a 10,000-job
|
|
154
|
+
sweep would otherwise be 10,000 INFO lines), **WARNING** for a failed job or a
|
|
155
|
+
clamped worker count, **ERROR** for a dead worker.
|
|
156
|
+
|
|
157
|
+
Every run also writes `<log_dir>/<run_id>/run.log` containing the parent's
|
|
158
|
+
whole view of that run — dispatch, milestones, every job outcome, the summary —
|
|
159
|
+
alongside the existing per-worker log files. That happens regardless of how you
|
|
160
|
+
configure logging, so a run is always a self-contained durable record.
|
|
161
|
+
|
|
162
|
+
**Progress display is terminal-aware.** Interactively you get the tqdm bars and
|
|
163
|
+
per-worker lines as before. When stderr is not a terminal — piped, redirected to
|
|
164
|
+
a log file, running under CI or a scheduler — those are suppressed (they render
|
|
165
|
+
as unreadable escape-sequence noise in a file) and periodic completion
|
|
166
|
+
milestones are logged at INFO instead.
|
|
167
|
+
|
|
133
168
|
### Closures and lambdas
|
|
134
169
|
|
|
135
170
|
`func`, and any custom `save_fn`, can be a closure or a lambda — not just a
|
|
@@ -168,6 +203,11 @@ for triangular workloads — where item `i` costs `O(i)`, such as an
|
|
|
168
203
|
expanding-window computation — which keeps workers from idling while one
|
|
169
204
|
overloaded worker finishes.
|
|
170
205
|
|
|
206
|
+
## Changelog
|
|
207
|
+
|
|
208
|
+
See [CHANGELOG.md](CHANGELOG.md). Versions below 1.0 may carry breaking changes
|
|
209
|
+
in a minor release; each is listed there with the migration needed.
|
|
210
|
+
|
|
171
211
|
## What else is in the repo
|
|
172
212
|
|
|
173
213
|
The `src/learning/` package holds demo and exercise scripts used while
|
|
@@ -1,152 +0,0 @@
|
|
|
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_workers: int = os.cpu_count() or 4,
|
|
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
|
-
Named `n_workers`, not `n_threads`: this spawns OS *processes* via
|
|
96
|
-
`mp.Pool`, each with its own interpreter and memory space, not threads
|
|
97
|
-
sharing one. That distinction matters here specifically - ex02 measured
|
|
98
|
-
that CPython's GIL makes threads add nothing for CPU-bound work, which is
|
|
99
|
-
exactly what this engine dispatches.
|
|
100
|
-
|
|
101
|
-
Results arrive in *completion* order, not submission order, which is what
|
|
102
|
-
lets `report_progress` report honestly as each job lands - with uneven job
|
|
103
|
-
costs (see ex03), an early-submitted heavy job can finish long after
|
|
104
|
-
several later-submitted light ones.
|
|
105
|
-
|
|
106
|
-
Each job is serialized with `cloudpickle` before submission (rather than
|
|
107
|
-
relying on `Pool`'s own stdlib-`pickle` handling of `jobs`), so a job's
|
|
108
|
-
'func' - or a nested callable, like `orchestrator.run`'s `save_fn` - can be
|
|
109
|
-
a closure or a lambda, not just a module-level function. `_run_from_blob`
|
|
110
|
-
is itself a plain module-level function, so it still pickles by reference
|
|
111
|
-
with no dependence on `__main__` spawn fixup.
|
|
112
|
-
|
|
113
|
-
`on_progress`, if given, is called as `on_progress(pid, atom_seconds,
|
|
114
|
-
result)` for every completed job - `pid` identifies which worker process
|
|
115
|
-
produced it and `atom_seconds` is that job's pure compute time as measured
|
|
116
|
-
inside the worker, which is what lets a caller (see `orchestrator.run`'s
|
|
117
|
-
`show_progress`) drive a per-worker live display with real timings. The
|
|
118
|
-
return value here is unaffected either way - still the plain `list[Any]`
|
|
119
|
-
of results, never the `(pid, atom_seconds, result)` triples.
|
|
120
|
-
Supplying `on_progress` means the caller is rendering its own progress
|
|
121
|
-
display, so the book's own text-based `report_progress` is skipped for
|
|
122
|
-
that call - the two would otherwise both write to stderr and garble each
|
|
123
|
-
other's redraws.
|
|
124
|
-
|
|
125
|
-
Uses explicit close()+join() rather than `with mp.Pool(...) as pool:`,
|
|
126
|
-
whose __exit__ calls terminate() - an abrupt kill, not the book's graceful
|
|
127
|
-
shutdown. The try/finally still lets a job's exception propagate while
|
|
128
|
-
guaranteeing the workers are torn down.
|
|
129
|
-
"""
|
|
130
|
-
if task is None:
|
|
131
|
-
task = jobs[0]["func"].__name__
|
|
132
|
-
blobs = [cloudpickle.dumps(job) for job in jobs]
|
|
133
|
-
# `Pool(processes=n)` spawns exactly n OS processes immediately, whether
|
|
134
|
-
# or not there's n jobs' worth of work - never spin up more workers than
|
|
135
|
-
# there are jobs to hand them. This only ever clamps downward: when jobs
|
|
136
|
-
# outnumber n_workers, no clamp is needed at all - the same fixed pool
|
|
137
|
-
# keeps pulling jobs off the queue, one per worker at a time, until the
|
|
138
|
-
# whole list is done. Worker count is sized to hardware, not job count.
|
|
139
|
-
pool = mp.Pool(processes=min(n_workers, len(jobs)))
|
|
140
|
-
out: list[Any] = []
|
|
141
|
-
time0 = time.time()
|
|
142
|
-
try:
|
|
143
|
-
for i, (pid, atom_s, out_) in enumerate(pool.imap_unordered(_run_from_blob, blobs), 1):
|
|
144
|
-
out.append(out_)
|
|
145
|
-
if on_progress is not None:
|
|
146
|
-
on_progress(pid, atom_s, out_)
|
|
147
|
-
else:
|
|
148
|
-
report_progress(i, len(jobs), time0, task)
|
|
149
|
-
finally:
|
|
150
|
-
pool.close()
|
|
151
|
-
pool.join()
|
|
152
|
-
return out
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|