Multiprocessing¶
In this section of the tutorial we will learn how to speed up the computation of evolutionary algorithms by using multiprocessing. The distribution of workloads over multiple cores or computing clusters requires the serialization of data objects, which is usually done by pickling, therefore all objects that are to be distributed (e.g. functions and their arguments) must be pickleable.
The correct way of using multiprocessing with DEAP-ER is to override the
default map function in the toolbox with one that supports parallel execution.
The only requirement of this map function is that its signature and return
type must match with the regular map function. This enables the use of any
third-party distributed computing libraries, such as
Ray,
that implement the same interface.
With context manager
# Using the multiprocessing library
with multiprocessing.Pool() as pool:
toolbox.register('map', pool.map)
# Execute the evolution
# Using the concurrent.futures library
with concurrent.futures.ProcessPoolExecutor() as executor:
toolbox.register('map', executor.map)
# Execute the evolution
Without context manager
# Using the multiprocessing library
pool = multiprocessing.Pool()
toolbox.register('map', pool.map)
# Execute the evolution
pool.close()
pool.join()
# Using the concurrent.futures library
executor = concurrent.futures.ProcessPoolExecutor()
toolbox.register('map', executor.map)
# Execute the evolution
executor.shutdown()
Note
It is also suggested to take a look at the full multiprocessing example.
Sharing a large dataset¶
Every item a parallel map hands to a worker has to be pickled. When
the evaluation reads a large array, the way that array reaches the
worker decides whether parallelism helps at all.
Attention
Do not bind the dataset into the evaluation operator:
The array becomes part of the registered callable, so it is pickled and sent again for every individual. On a dataset of any real size that cost dwarfs the evaluation itself.
Build the arrays once, before the pool starts, and let the evaluation read them from the enclosing scope. The individual then stays the only thing that travels:
columns = load_columns() # built before the pool exists
def evaluate(individual):
func = toolbox.compile(expr=individual)
return (score(func(*columns)),)
toolbox.register("evaluate", evaluate)
with multiprocessing.Pool() as pool:
toolbox.register("map", pool.map)
# Execute the evolution
On platforms that start workers by forking, which is the default on
Linux, the child inherits those pages copy-on-write and never copies the
data at all. On platforms that start workers by spawning, which is the
default on Windows and macOS, place the arrays in
shared memory
or a numpy.memmap and have each worker attach to them once at import.
Reproducible worker streams¶
A process pool gives each worker a fresh interpreter. On fork the
child inherits a copy of tools.rng; on spawn it starts an
unseeded generator. Either way a golden run is lost: workers share
one stream, or they draw unreproducible values, and completion
order can change which item sees which draw.
Derive one stream per mapped item from the same seed passed to
tools.rng.seed. spawn_rng(seed, worker_id) does not advance
the parent generator, so Checkpoint still restores the
in-process run.
from functools import partial
tools.rng.seed(1234)
def evaluate(individual):
noise = tools.rng.random()
return (score(individual) + noise,)
toolbox.register("evaluate", evaluate)
with multiprocessing.Pool() as pool:
toolbox.register(
"map",
partial(tools.map_spawned, seed=1234, map_func=pool.map),
)
# Execute the evolution
Item i always sees spawn_rng(1234, i), including when the
pool finishes tasks out of order. map_spawned reassembles
results by that id. For a worker that should keep one stream for
its whole lifetime, call
tools.bind_spawned_rng(seed, worker_id) in the pool
initializer instead.
Numba workers¶
On platforms that start workers by spawning, each child process pays
for the Numba interpreter unless it is warmed from disk. Set
NUMBA_CACHE_DIR to a directory every worker can read and write, or
let gp.warmup_numba() create ~/.cache/deap-er/numba (or
$XDG_CACHE_HOME/deap-er/numba) when the variable is unset. Register
a pool initializer so the interpreter is specialized before the first
evaluation:
def init_worker():
gp.warmup_numba()
with multiprocessing.Pool(initializer=init_worker) as pool:
toolbox.register("map", pool.map)
# Execute the evolution
Pass the same dispatch kernel to warmup_numba(dispatch=...) when
the primitive set uses consumer opcodes. Give consumer kernels
cache=True so workers reload them from disk as well.
Evaluating a whole generation at once¶
Some evaluations are faster when the entire generation is handed over in a single call, for instance when the work is dispatched to a GPU or to a vectorized kernel that amortizes its setup.
Register an evaluate_batch operator for that. It takes the list of
individuals whose fitness is invalid and returns their fitness values
in the same order. When it is present, ea_simple, ea_mu_plus_lambda,
ea_mu_comma_lambda, ea_map_elites, ea_generate_update,
ea_generate_update_restarts, step_islands, and harm call it
instead of going through map and evaluate.
def evaluate_batch(individuals):
unique = {}
tapes = []
index = []
for ind in individuals:
key = str(ind)
slot = unique.get(key)
if slot is None:
unique[key] = slot = len(tapes)
tapes.append(gp.lower_tree(ind, pset))
index.append(slot)
predicted = gp.interpret_tapes(tapes, matrix, backend="numba")
return [score(predicted[i], target) for i in index]
toolbox.register("evaluate_batch", evaluate_batch)
Key unique programs by str(ind) and lower the tree object. Do not
pass the string to lower_tree: PrimitiveTree.from_string cannot
round-trip a Window ephemeral. The fitness list must have one
entry per individual — evaluate_invalid zips without strict.
parallel=True is in-process Numba threading (one workspace per
thread, each as long as the book). A consumer dispatch kernel must
be safe on several stacks at once. It does not replace a process
pool. Leave it off when the series is millions of bars, or when a
pool is already running.
Note
evaluate_batch replaces map for evaluation, so a batch operator
is responsible for its own parallelism. Leave it unregistered to
keep the ordinary per-individual path.
A growing matrix and unlike demes stay on the caller loop. Append
rows, invalidate fitness, and rescore with interpret_tapes on the
full pack — see the columnar tutorial. Step unlike
toolboxes with step_islands, then mig_ring, mig_fully_connected,
or mig_random. Use island_eval_keys when demes evaluate on
different exams or matrices. Persist with
Checkpoint.range. Do not stand up a Ray or GPU evaluation daemon
for that recipe; evaluate_batch already owns any process pool.
Attention
When using multiprocessing on Windows, the main function needs to be guarded
with the if __name__ == '__main__' statement.
Tip
An excellent third-party tutorial about multiprocessing in Python is available here. The reference manuals of multiprocessing and concurrent.futures should also prove useful.