parallel¶
The shared worker pools of the analyses.
The non-compartmental analysis and the fit both spread their rows over
workers, and both used to create a fresh concurrent.futures.Executor per
call. A process pool starts its workers with forkserver or spawn
(PROCESS_START_METHOD), so every worker of a new pool imports pkpdutils,
numpy, scipy, xarray and pint from scratch, about 0.7 s per pool, and
a one-shot analysis was slower with workers than without them. This module
therefore keeps one executor per kind and size, created on first use and
closed at interpreter exit, so that the start-up is paid once per process
instead of once per call.
The two analyses use different kinds of workers:
- the NCA core is vectorized numpy over a chunk of rows and releases the GIL for most of its time, so its chunks run in threads: no pickling, no copy of the batch, and a pool that starts in half a millisecond;
- a fit row is a python-heavy
scipy.optimize.least_squaressearch, so the rows run in processes, which is where the GIL is actually escaped.
resolve_workers turns n_workers into a worker count: None is automatic
and stays serial below a row threshold, 1 is serial and any other number is
taken as given. split_rows cuts the rows into about one contiguous slice
per worker, bounded from below so that a worker gets enough work to pay for
itself and from above so that the memory of the vectorized core stays bounded
(NCAOptions.chunk_rows).
The two pools live side by side in one process, so the process pool never
forks. With the fork start method, the default of python 3.13 on Linux, a
worker would be forked from a parent whose NCA threads may hold a lock at
that moment, and the child would inherit the locked lock and could block
forever; python 3.13 warns about it (DeprecationWarning: This process is
multi-threaded, use of fork() may lead to deadlocks in the child), and python
3.14 no longer forks by default. The process pool therefore starts its
workers with PROCESS_START_METHOD on every python version, the default of
python 3.14: forkserver on Linux and the other POSIX platforms which offer
it, spawn on macOS and Windows, whatever multiprocessing.set_start_method
chose for the rest of the program. Both start a fresh interpreter which
imports the main module without running it, so a pooled call needs an
if __name__ == "__main__": guard, and what it sends to the workers (a model
of the fit) must be importable, not defined in an interactive session.
executor
¶
The shared executor of a kind and size, created on first use.
The executor is cached and reused for the life of the process and closed
by an atexit handler (shutdown_executors), so the start-up of a
process pool is paid once and not once per call. A cached pool that is
broken or shut down is dropped and replaced, so that one dead worker does
not fail every later call of the process. A process executor starts its
workers with PROCESS_START_METHOD (forkserver or spawn, never
fork, whatever the default of the platform or
multiprocessing.set_start_method says), so the caller must run under an
if __name__ == "__main__": guard.
The pools are not re-entrant: work running in a worker of a pool must not
submit to that same pool and wait for the result, which deadlocks once
every worker waits (calling pkpdutils.nca.nca from a chunk of an NCA
that is already running in the shared thread pool, for instance). The
analyses of the package never do.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
kind
|
ExecutorKind
|
|
required |
n_workers
|
int
|
number of workers, at least 1 |
required |
Returns:
| Type | Description |
|---|---|
Executor
|
The executor; two calls with the same kind and size return the same |
Executor
|
object while it is usable. |
evict
¶
Drop the shared executor of a kind and size and shut it down.
The caller of a pool that failed (a worker process that died takes the
whole ProcessPoolExecutor with it) evicts it before it retries: the next
executor call then builds a fresh pool. Evicting an executor that is not
cached does nothing.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
kind
|
ExecutorKind
|
the kind of the executor. |
required |
n_workers
|
int
|
the number of workers it was created with. |
required |
shutdown_executors
¶
Close every shared executor and forget it.
Registered with atexit, so a script does not have to close the pools it
used; a later call to executor creates a new one.
resolve_workers
¶
The number of workers of a run over n_rows rows.
None is the automatic default: a run below threshold rows is serial,
since the pool costs more than it saves, and a larger one uses one worker
per usable core up to max_workers (the scaling of the shared-memory core
flattens there). The cores are counted with os.process_cpu_count, which
honours the CPU affinity of the process, a cgroup quota and
PYTHON_CPU_COUNT, so a process pinned to two cores of a cluster node
uses two workers. An explicit n_workers is taken as given, 1 being the
serial run.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n_workers
|
int | None
|
the option, |
required |
n_rows
|
int
|
number of rows of the run |
required |
Other Parameters:
| Name | Type | Description |
|---|---|---|
threshold |
int
|
rows from which the automatic default uses workers |
max_workers |
int
|
upper bound of the automatic worker count |
Returns:
| Type | Description |
|---|---|
int
|
The number of workers, 1 for a serial run. |
split_rows
¶
Cut n_rows rows into contiguous slices, about one per worker.
The slices are contiguous and cover every row in order, so a chunk of an
array is a view and not a copy. There are about n_workers of them: never
more than one per min_rows rows, so that a worker gets enough work to
pay for its share of the overhead (a batch below min_rows rows stays one
slice), and never a slice longer than max_rows, the bound on the memory
of the vectorized core, which can force more slices than there are
workers.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
n_rows
|
int
|
number of rows, 0 or more |
required |
n_workers
|
int
|
number of workers, 1 or more |
required |
Other Parameters:
| Name | Type | Description |
|---|---|---|
min_rows |
int
|
fewest rows a slice carries while there is more than one |
max_rows |
int | None
|
most rows a slice carries, |
Returns:
| Type | Description |
|---|---|
list[slice]
|
The slices in row order; empty for |