mpengine 0.2.0__tar.gz → 0.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {mpengine-0.2.0/src/mpengine.egg-info → mpengine-0.3.1}/PKG-INFO +42 -2
- {mpengine-0.2.0 → mpengine-0.3.1}/README.md +41 -1
- {mpengine-0.2.0 → mpengine-0.3.1}/pyproject.toml +1 -1
- {mpengine-0.2.0 → mpengine-0.3.1}/src/mpengine/__init__.py +11 -1
- {mpengine-0.2.0 → mpengine-0.3.1}/src/mpengine/banner.py +2 -9
- mpengine-0.3.1/src/mpengine/engine.py +276 -0
- mpengine-0.3.1/src/mpengine/orchestrator.py +788 -0
- {mpengine-0.2.0 → mpengine-0.3.1}/src/mpengine/partition.py +50 -1
- {mpengine-0.2.0 → mpengine-0.3.1}/src/mpengine/spider.txt +2 -2
- {mpengine-0.2.0 → mpengine-0.3.1}/src/mpengine/worker_names.json +1 -1
- {mpengine-0.2.0 → mpengine-0.3.1/src/mpengine.egg-info}/PKG-INFO +42 -2
- mpengine-0.2.0/src/mpengine/engine.py +0 -152
- mpengine-0.2.0/src/mpengine/orchestrator.py +0 -445
- {mpengine-0.2.0 → mpengine-0.3.1}/LICENSE +0 -0
- {mpengine-0.2.0 → mpengine-0.3.1}/setup.cfg +0 -0
- {mpengine-0.2.0 → mpengine-0.3.1}/src/mpengine.egg-info/SOURCES.txt +0 -0
- {mpengine-0.2.0 → mpengine-0.3.1}/src/mpengine.egg-info/dependency_links.txt +0 -0
- {mpengine-0.2.0 → mpengine-0.3.1}/src/mpengine.egg-info/requires.txt +0 -0
- {mpengine-0.2.0 → mpengine-0.3.1}/src/mpengine.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mpengine
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: A small, general-purpose multiprocessing engine: dispatch any callable across processes with run manifests, per-worker logs, on-disk outputs and failure isolation
|
|
5
5
|
Author: Amandeep Singh
|
|
6
6
|
License-Expression: MIT
|
|
@@ -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.1"
|
|
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.1"
|
|
@@ -1,12 +1,5 @@
|
|
|
1
|
-
"""
|
|
2
|
-
|
|
3
|
-
`orchestrator.py` imports exactly one name from here and calls it once. All the
|
|
4
|
-
ASCII art, formatting and printing lives in this module, so the engine and the
|
|
5
|
-
orchestration layer stay free of decoration.
|
|
6
|
-
|
|
7
|
-
The spider is the mascot: a multiprocessing engine, and a creature famous for
|
|
8
|
-
having eight legs. The art itself lives in `spider.txt` rather than as a string
|
|
9
|
-
literal here - it is data, not code, the same call made for `worker_names.json`.
|
|
1
|
+
"""
|
|
2
|
+
The run "spider" banner - purely cosmetic, deliberately kept out of everything else.
|
|
10
3
|
"""
|
|
11
4
|
|
|
12
5
|
from __future__ import annotations
|
|
@@ -0,0 +1,276 @@
|
|
|
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
|
+
# Lifecycle logging (dispatch start/done, worker-count clamping, progress
|
|
29
|
+
# milestones) is always captured in full by orchestrator.run()'s run.log, but
|
|
30
|
+
# is deliberately never allowed to bubble up past this logger to whatever the
|
|
31
|
+
# CALLER's own logging setup is (root, via logging.basicConfig or similar) -
|
|
32
|
+
# a caller configuring logging for their own unrelated purposes should not
|
|
33
|
+
# suddenly also see mpengine's internal chatter. Only "mpengine.summary" (the
|
|
34
|
+
# stored-here paths and the worker ranking, see orchestrator.py) is meant to
|
|
35
|
+
# surface there.
|
|
36
|
+
_log.propagate = False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def expand_call(kargs: dict[str, Any]) -> Any:
|
|
40
|
+
"""Ch.20 Snippet 20.10 (`expandCall`) - turn a job dict into a call.
|
|
41
|
+
|
|
42
|
+
Deviation from the book: `kargs` is copied before popping. The book's
|
|
43
|
+
literal `kargs.pop('func')` mutates the *caller's* dict in place, so the
|
|
44
|
+
same job list can only ever be run once - a second pass raises
|
|
45
|
+
KeyError('func'). ex04 deliberately runs one job list through both
|
|
46
|
+
`process_jobs_` and `process_jobs` to compare them, so the copy matters.
|
|
47
|
+
"""
|
|
48
|
+
kargs = dict(kargs)
|
|
49
|
+
func = kargs.pop("func")
|
|
50
|
+
return func(**kargs)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def process_jobs_(jobs: list[dict[str, Any]]) -> list[Any]:
|
|
54
|
+
"""Ch.20 Snippet 20.8 (`processJobs_`) - sequential fallback, for debugging.
|
|
55
|
+
|
|
56
|
+
Runs every job in-process, one at a time, in submission order. No pool,
|
|
57
|
+
no pickling, no spawn - so a failing job raises immediately, at its exact
|
|
58
|
+
position in the list, with an ordinary live traceback you can attach a
|
|
59
|
+
debugger to. That clarity is the entire reason this mode exists.
|
|
60
|
+
"""
|
|
61
|
+
return [expand_call(job) for job in jobs]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def report_progress(job_num: int, num_jobs: int, time0: float, task: str) -> None:
|
|
65
|
+
"""Ch.20 Snippet 20.9's `reportProgress`, modernized.
|
|
66
|
+
|
|
67
|
+
Overwrites its own line via '\\r' until the final job, then emits '\\n'.
|
|
68
|
+
Units stay the book's minutes even though a fast demo run always shows
|
|
69
|
+
"0.00 minutes" - the snippet is sized for real multi-minute workloads.
|
|
70
|
+
"""
|
|
71
|
+
frac = job_num / num_jobs
|
|
72
|
+
minutes_elapsed = (time.time() - time0) / 60.0
|
|
73
|
+
minutes_remaining = minutes_elapsed * (1 / frac - 1) if frac > 0 else 0.0
|
|
74
|
+
timestamp = dt.datetime.now().isoformat(sep=" ", timespec="seconds")
|
|
75
|
+
msg = (
|
|
76
|
+
f"{timestamp} {frac * 100:5.1f}% {task} done after {minutes_elapsed:.2f} "
|
|
77
|
+
f"minutes. Remaining {minutes_remaining:.2f} minutes."
|
|
78
|
+
)
|
|
79
|
+
print(msg, end="\n" if job_num >= num_jobs else "\r", file=sys.stderr, flush=True)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _run_from_blob(blob: bytes) -> tuple[int, float, Any]:
|
|
83
|
+
"""Executor target: undo the cloudpickle wrapping `process_jobs` applies
|
|
84
|
+
before submission, then dispatch exactly as `expand_call` always has.
|
|
85
|
+
|
|
86
|
+
Returns `(pid, atom_seconds, result)`. The pid attributes the completion to
|
|
87
|
+
a specific worker process; `atom_seconds` is measured around the call
|
|
88
|
+
itself, *inside* the worker, so it is pure compute time - it cannot include
|
|
89
|
+
queueing, spawn cost, or the idle gap between this atom finishing and the
|
|
90
|
+
rest of the run finishing. That distinction is the whole point: timing the
|
|
91
|
+
same thing from the parent would measure when the result *arrived*, not how
|
|
92
|
+
long the work actually took.
|
|
93
|
+
"""
|
|
94
|
+
job = cloudpickle.loads(blob)
|
|
95
|
+
t0 = time.perf_counter()
|
|
96
|
+
result = expand_call(job)
|
|
97
|
+
return os.getpid(), time.perf_counter() - t0, result
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _infer_task_name(jobs: list[dict[str, Any]]) -> str:
|
|
101
|
+
"""Best-effort display name for a job list.
|
|
102
|
+
|
|
103
|
+
The obvious `jobs[0]["func"].__name__` crashes with AttributeError on the
|
|
104
|
+
very callables this engine goes out of its way to support: a
|
|
105
|
+
`functools.partial` (a natural way to pin one big fitted object once) and
|
|
106
|
+
any class instance implementing `__call__` have no `__name__`. Degrade to
|
|
107
|
+
the type name, then to a constant, rather than refusing to run.
|
|
108
|
+
"""
|
|
109
|
+
func = jobs[0].get("func")
|
|
110
|
+
return getattr(func, "__name__", None) or type(func).__name__ or "job"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def process_jobs(
|
|
114
|
+
jobs: list[dict[str, Any]],
|
|
115
|
+
task: str | None = None,
|
|
116
|
+
n_workers: int = os.cpu_count() or 4,
|
|
117
|
+
on_progress: Callable[[int, float, Any], None] | None = None,
|
|
118
|
+
on_job_error: Callable[[int, BaseException], None] | None = None,
|
|
119
|
+
text_progress: bool | None = None,
|
|
120
|
+
milestones: bool = True,
|
|
121
|
+
) -> list[Any]:
|
|
122
|
+
"""Ch.20 Snippet 20.9 (`processJobs`) - parallel dispatch over a process pool.
|
|
123
|
+
|
|
124
|
+
Named `n_workers`, not `n_threads`: this spawns OS *processes*, each with
|
|
125
|
+
its own interpreter and memory space, not threads sharing one. That
|
|
126
|
+
distinction matters here specifically - ex02 measured that CPython's GIL
|
|
127
|
+
makes threads add nothing for CPU-bound work, which is exactly what this
|
|
128
|
+
engine dispatches.
|
|
129
|
+
|
|
130
|
+
Results arrive in *completion* order, not submission order, which is what
|
|
131
|
+
lets `report_progress` report honestly as each job lands - with uneven job
|
|
132
|
+
costs (see ex03), an early-submitted heavy job can finish long after
|
|
133
|
+
several later-submitted light ones. (`orchestrator.run` re-sorts back to
|
|
134
|
+
submission order before returning, since its results are label-addressed.)
|
|
135
|
+
|
|
136
|
+
Each job is serialized with `cloudpickle` before submission (rather than
|
|
137
|
+
relying on the executor's own stdlib-`pickle` handling), so a job's 'func'
|
|
138
|
+
- or a nested callable, like `orchestrator.run`'s `save_fn` - can be a
|
|
139
|
+
closure or a lambda, not just a module-level function. `_run_from_blob` is
|
|
140
|
+
itself a plain module-level function, so it still pickles by reference with
|
|
141
|
+
no dependence on `__main__` spawn fixup.
|
|
142
|
+
|
|
143
|
+
Deliberately built on `concurrent.futures.ProcessPoolExecutor` rather than
|
|
144
|
+
the book's literal `mp.Pool` + `imap_unordered`. `mp.Pool` cannot detect a
|
|
145
|
+
worker that DIES mid-job (OOM-killed, or segfaulting inside native code
|
|
146
|
+
such as BLAS): CPython only resolves a task slot when a result arrives on
|
|
147
|
+
the output queue, and a dead process posts nothing, so the run blocks
|
|
148
|
+
forever with no exception and no log line. `ProcessPoolExecutor` watches
|
|
149
|
+
its workers and raises `BrokenProcessPool` instead - a hang that never
|
|
150
|
+
surfaces is far worse for an unattended run than a loud failure. The
|
|
151
|
+
`with` block here is also genuinely graceful, unlike `mp.Pool.__exit__`,
|
|
152
|
+
which calls `terminate()`.
|
|
153
|
+
|
|
154
|
+
`on_progress`, if given, is called as `on_progress(pid, atom_seconds,
|
|
155
|
+
result)` for every completed job - `pid` identifies which worker process
|
|
156
|
+
produced it and `atom_seconds` is that job's pure compute time as measured
|
|
157
|
+
inside the worker, which is what lets a caller (see `orchestrator.run`'s
|
|
158
|
+
`show_progress`) drive a per-worker live display with real timings. The
|
|
159
|
+
return value here is unaffected either way - still the plain `list[Any]`
|
|
160
|
+
of results, never the `(pid, atom_seconds, result)` triples.
|
|
161
|
+
`text_progress` controls the book's own `\\r`-overwriting stderr line.
|
|
162
|
+
Left at None it is automatic: emitted only when no `on_progress` display
|
|
163
|
+
is running AND stderr is a real terminal. That distinction matters in
|
|
164
|
+
production - redirected to a log file, a `\\r` line per job is unreadable
|
|
165
|
+
noise, so when the text display is off this logs periodic completion
|
|
166
|
+
milestones at INFO instead - unless `milestones=False`, which a caller
|
|
167
|
+
passes when `on_progress` is itself a live visual display (a tqdm bar,
|
|
168
|
+
say) rather than merely a logging hook: the milestones would otherwise be
|
|
169
|
+
redundant with - and print right alongside - that display. `on_progress`
|
|
170
|
+
alone isn't the right signal for this, since a caller can legitimately
|
|
171
|
+
want per-job callbacks (for logging, say) with no visual display at all.
|
|
172
|
+
|
|
173
|
+
`on_job_error`, if given, is called as `on_job_error(index, exc)` for any
|
|
174
|
+
job that cannot be *serialized* for submission, and that job is skipped
|
|
175
|
+
instead of sinking the batch. Jobs are cloudpickled up front, so without
|
|
176
|
+
this one unpicklable payload (a lock, a live socket, an open file handle
|
|
177
|
+
captured by a closure) raises before any job runs at all - job 517 of 1000
|
|
178
|
+
taking down the 999 that were perfectly runnable. Left as None, that
|
|
179
|
+
original fail-fast behaviour is preserved, which keeps this layer
|
|
180
|
+
book-faithful; `orchestrator.run` opts in to turn such a failure into one
|
|
181
|
+
failed job, matching the per-job isolation it already promises.
|
|
182
|
+
"""
|
|
183
|
+
if not jobs:
|
|
184
|
+
return []
|
|
185
|
+
if task is None:
|
|
186
|
+
task = _infer_task_name(jobs)
|
|
187
|
+
|
|
188
|
+
# Serialize per job rather than in one comprehension, so a single
|
|
189
|
+
# unpicklable payload can be attributed and skipped instead of aborting
|
|
190
|
+
# the whole submission (see `on_job_error`).
|
|
191
|
+
blobs: list[bytes] = []
|
|
192
|
+
for i, job in enumerate(jobs):
|
|
193
|
+
try:
|
|
194
|
+
blobs.append(cloudpickle.dumps(job))
|
|
195
|
+
except Exception as exc:
|
|
196
|
+
if on_job_error is None:
|
|
197
|
+
raise
|
|
198
|
+
_log.warning(
|
|
199
|
+
"job could not be serialized for dispatch, skipping "
|
|
200
|
+
"task=%s job_index=%d error=%s", task, i, exc,
|
|
201
|
+
)
|
|
202
|
+
on_job_error(i, exc)
|
|
203
|
+
if not blobs:
|
|
204
|
+
_log.warning("nothing dispatchable task=%s n_jobs=%d", task, len(jobs))
|
|
205
|
+
return []
|
|
206
|
+
|
|
207
|
+
# Never spin up more workers than there are jobs to hand them: the
|
|
208
|
+
# executor starts its processes eagerly, so surplus workers pay full spawn
|
|
209
|
+
# cost to do nothing. This only ever clamps downward - when jobs outnumber
|
|
210
|
+
# n_workers no clamp is needed, since the same fixed pool keeps pulling
|
|
211
|
+
# jobs until the list is done. Worker count is sized to hardware, not to
|
|
212
|
+
# job count.
|
|
213
|
+
out: list[Any] = []
|
|
214
|
+
time0 = time.time()
|
|
215
|
+
n_submitted = len(blobs)
|
|
216
|
+
n_pool = min(n_workers, n_submitted)
|
|
217
|
+
if n_pool < n_workers:
|
|
218
|
+
_log.warning(
|
|
219
|
+
"n_workers=%d clamped to %d task=%s reason=fewer jobs than workers",
|
|
220
|
+
n_workers, n_pool, task,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# Auto: the book's carriage-return line is a terminal affordance, so only emit it when
|
|
224
|
+
# a terminal is actually there and nothing else owns the display.
|
|
225
|
+
if text_progress is None:
|
|
226
|
+
text_progress = on_progress is None and sys.stderr.isatty()
|
|
227
|
+
# When nothing is drawing live, log milestones so a redirected run still
|
|
228
|
+
# shows movement - roughly ten lines, whatever the job count. `milestones`
|
|
229
|
+
# is the caller's explicit say on this, separate from text_progress/
|
|
230
|
+
# on_progress: it's the only signal that actually means "something else
|
|
231
|
+
# is already showing a visual progress display, don't duplicate it."
|
|
232
|
+
milestone = max(1, n_submitted // 10) if (not text_progress and milestones) else 0
|
|
233
|
+
|
|
234
|
+
_log.info(
|
|
235
|
+
"dispatch start task=%s n_jobs=%d n_workers=%d text_progress=%s",
|
|
236
|
+
task, n_submitted, n_pool, text_progress,
|
|
237
|
+
)
|
|
238
|
+
with ProcessPoolExecutor(max_workers=n_pool) as executor:
|
|
239
|
+
futures = [executor.submit(_run_from_blob, blob) for blob in blobs]
|
|
240
|
+
try:
|
|
241
|
+
for i, future in enumerate(as_completed(futures), 1):
|
|
242
|
+
pid, atom_s, out_ = future.result()
|
|
243
|
+
out.append(out_)
|
|
244
|
+
if on_progress is not None:
|
|
245
|
+
on_progress(pid, atom_s, out_)
|
|
246
|
+
if text_progress:
|
|
247
|
+
report_progress(i, n_submitted, time0, task)
|
|
248
|
+
elif milestone and (i % milestone == 0 or i == n_submitted):
|
|
249
|
+
_log.info(
|
|
250
|
+
"progress task=%s done=%d/%d pct=%.0f elapsed_s=%.2f",
|
|
251
|
+
task, i, n_submitted, 100.0 * i / n_submitted,
|
|
252
|
+
time.time() - time0,
|
|
253
|
+
)
|
|
254
|
+
except BrokenProcessPool as exc:
|
|
255
|
+
# Cancel whatever has not started so shutdown does not block on
|
|
256
|
+
# work that can never complete, then re-raise with the context the
|
|
257
|
+
# bare stdlib error lacks: how far the run actually got.
|
|
258
|
+
for future in futures:
|
|
259
|
+
future.cancel()
|
|
260
|
+
_log.error(
|
|
261
|
+
"worker process died task=%s completed=%d/%d - run aborted",
|
|
262
|
+
task, len(out), n_submitted,
|
|
263
|
+
)
|
|
264
|
+
raise BrokenProcessPool(
|
|
265
|
+
f"a worker process died during task {task!r} after "
|
|
266
|
+
f"{len(out)}/{n_submitted} jobs completed - it was most likely "
|
|
267
|
+
f"OOM-killed or crashed inside native code (e.g. BLAS). The "
|
|
268
|
+
f"completed jobs' results were lost with the pool; if you need "
|
|
269
|
+
f"per-job durability use orchestrator.run, which saves each "
|
|
270
|
+
f"result to disk as it lands."
|
|
271
|
+
) from exc
|
|
272
|
+
_log.info(
|
|
273
|
+
"dispatch done task=%s completed=%d/%d elapsed_s=%.2f",
|
|
274
|
+
task, len(out), n_submitted, time.time() - time0,
|
|
275
|
+
)
|
|
276
|
+
return out
|