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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mpengine
3
- Version: 0.2.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 `RemoteTraceback` from a worker that has already exited,
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 `RemoteTraceback` from a worker that has already exited,
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.2.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.2.0"
86
+ __version__ = "0.3.1"
@@ -1,12 +1,5 @@
1
- """The run banner - purely cosmetic, deliberately kept out of everything else.
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