Skip to content

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_squares search, 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

executor(kind, n_workers)

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

"thread" for a ThreadPoolExecutor, "process" for a ProcessPoolExecutor

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

evict(kind, n_workers)

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

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

resolve_workers(
    n_workers,
    n_rows,
    *,
    threshold=NCA_WORKER_THRESHOLD,
    max_workers=8,
)

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, None for automatic

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

split_rows(
    n_rows, n_workers, *, min_rows=1000, max_rows=None
)

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, None for no bound

Returns:

Type Description
list[slice]

The slices in row order; empty for n_rows = 0.