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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mpengine
3
- Version: 0.2.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 `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.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.2.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
- return {p.name: load_fn(p) for p in sorted(path.iterdir()) if p.is_file()}
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 Pool/sequential target. Unwraps its own bookkeeping fields,
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, output_path)
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(available_names)
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 _print_worker_ranking(stats: dict[int, WorkerStats]) -> None:
388
+ def _log_worker_ranking(stats: dict[int, WorkerStats]) -> None:
274
389
  """Rank workers fastest-to-slowest by seconds per atom.
275
390
 
276
- Note the caveat printed alongside: with uneven atom sizes, a low
277
- s/atom can mean small atoms rather than a genuinely faster worker.
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
- print("\nworkers, fastest to slowest (by avg seconds per atom):")
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
- print(
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
- print(" (uneven atom sizes: a low s/atom can mean small atoms, not a faster worker)")
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
- task = task or func.__name__
357
- run_id = f"{task}_{dt.datetime.now():%Y%m%d_%H%M%S}"
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: {func.__name__}\n")
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
- worker_stats: dict[int, WorkerStats] = {}
400
-
401
- print_banner(task, n_workers, debug)
402
-
403
- t0 = time.perf_counter()
404
- if debug:
405
- raw_results = process_jobs_(jobs)
406
- elif show_progress:
407
- with _progress_renderer(len(jobs), task, worker_stats) as on_progress:
408
- raw_results = process_jobs(jobs, task=task, n_workers=n_workers, on_progress=on_progress)
409
- else:
410
- raw_results = process_jobs(jobs, task=task, n_workers=n_workers)
411
- elapsed_s = time.perf_counter() - t0
412
-
413
- if show_progress and not debug:
414
- _print_worker_ranking(worker_stats)
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
- n_ok = sum(1 for r in raw_results if r.status == "ok")
417
- n_failed = len(raw_results) - n_ok
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
- with open(manifest_path, "a") as f:
420
- f.write("results:\n")
421
- for r in raw_results:
422
- if r.status == "ok":
423
- f.write(f" {r.label}: ok -> {r.output_path}\n")
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
- f.write(f" {r.label}: ERROR - {r.error}\n")
426
- f.write(f"\nn_ok: {n_ok}\n")
427
- f.write(f"n_failed: {n_failed}\n")
428
- f.write(f"elapsed_s: {elapsed_s:.3f}\n")
429
-
430
- print(f"Logs stored here - {log_path}")
431
- print(f"Output stored here - {output_path}")
432
- print(f"Manifest stored here - {manifest_path}")
433
-
434
- return RunSummary(
435
- run_id=run_id,
436
- manifest_path=str(manifest_path),
437
- output_dir=str(output_path),
438
- log_dir=str(log_path),
439
- n_jobs=len(jobs),
440
- n_ok=n_ok,
441
- n_failed=n_failed,
442
- elapsed_s=elapsed_s,
443
- results=raw_results,
444
- worker_stats=worker_stats,
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 = np.round(parts).astype(int)
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.2.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 `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
@@ -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