mpengine 0.1.0__py3-none-any.whl

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