Source code for TACTICS.thompson_sampling.core.parallel_evaluator

"""
Parallel evaluation wrapper for Thompson Sampling.

Handles multi-process evaluation of compound batches using multiprocessing.Pool.

Why this module is not a plain ``pool.map(sampler.evaluate, ...)``
------------------------------------------------------------------
``pool.map`` serialises the task function and its arguments with ``pickle`` to
send them down a pipe to each worker. ``sampler.evaluate`` is a bound method,
so pickling it means pickling the sampler, which owns the evaluator. For
OpenEye-backed evaluators the evaluator holds a SWIG-wrapped C++ object
(``OEDock`` for Fred, the shape engine for ROCS), and SWIG objects raise
``TypeError: cannot pickle 'SwigPyObject' object`` on any pickle attempt.
The result was that ``processes > 1`` crashed on the very first batch for
exactly the slow evaluators that need parallelism most.

The fix is to never send an evaluator across the pipe. Each worker builds its
*own* evaluator instead, once, from the evaluator's picklable Pydantic config
(a receptor file path plus options). See :func:`_init_worker`.

Why the pool uses the ``spawn`` start method
--------------------------------------------
On Linux ``multiprocessing.Pool`` defaults to ``fork``. A forked worker
inherits the parent's copy of Polars' Rayon thread pool, whose threads do not
exist in the child, so the first Polars call in the worker -- reading the
reference table when :func:`_init_worker` rebuilds a ``LookupEvaluator`` --
deadlocks forever. macOS and Windows default to ``spawn`` and never see this,
which is how it reached CI unnoticed. ``spawn`` starts each worker from a fresh
interpreter, so every worker builds its own Polars, RDKit and OpenEye state.
The cost is one interpreter start-up per worker, which is negligible against
the slow evaluators that ``processes > 1`` exists for.
"""
import multiprocessing
from typing import Any, Callable, List, Optional, Tuple

# Worker-process globals. Populated once per worker by _init_worker() and
# reused for every task that worker handles. They only ever exist inside a
# worker process; in the parent they stay None.
_worker_sampler = None


def _init_worker(sampler, evaluator_config):
    """Pool initializer: rebuild this worker's own evaluator.

    Runs exactly once per worker process, at pool creation. ``sampler`` arrives
    without its evaluator (see ``ThompsonSampler.__getstate__``), and this
    function constructs a fresh evaluator here, inside the worker, from the
    picklable ``evaluator_config``. The evaluator -- and any SWIG object it
    owns, such as ``OEDock`` -- is therefore created in the worker and never
    crosses the pickle boundary.

    Building it in the initializer rather than per task also means the design
    unit is read and the docking engine constructed once per worker, not once
    per molecule.

    Args:
        sampler: A ThompsonSampler pickled without its evaluator, pool, or logger.
        evaluator_config: Picklable evaluator config (a Pydantic model).
    """
    global _worker_sampler

    # Imported here rather than at module scope to avoid a circular import
    # (factories -> evaluators -> ... -> sampler -> parallel_evaluator).
    from ..factories import create_evaluator

    sampler.set_evaluator(create_evaluator(evaluator_config))
    _worker_sampler = sampler


def _worker_evaluate(choice_list):
    """Top-level task function, therefore picklable.

    Only the lightweight ``choice_list`` (a list of reagent indices) is sent
    over the pipe. The sampler and evaluator are already resident in the
    worker, installed by :func:`_init_worker`.
    """
    return _worker_sampler.evaluate(choice_list)


[docs] class ParallelEvaluator: """ Wrapper for parallel evaluation of compounds with persistent pool. The pool is created once and reused across all evaluations to avoid the overhead of repeatedly spawning processes. Two execution paths: - ``processes == 1``: sequential, calling ``evaluate_fn`` directly. No pickling and no worker processes. - ``processes > 1``: a persistent pool whose workers each construct their own evaluator via :func:`_init_worker`. Requires a worker context to have been bound with :meth:`bind_worker_context`; without one, falls back to sending ``evaluate_fn`` itself, which works for picklable evaluators but not for OpenEye-backed ones. """ def __init__(self, processes: int = 1): """ Initialize parallel evaluator. Args: processes: Number of CPU cores to use. If 1, uses sequential evaluation. """ self.processes = processes self._pool: Optional[Any] = None self._worker_context: Optional[Tuple[Any, Any]] = None
[docs] def bind_worker_context(self, sampler, evaluator_config) -> None: """Record what each worker needs in order to rebuild its own evaluator. Called by ``ThompsonSampler.set_evaluator`` when the evaluator's config is known (i.e. when the sampler was built by ``from_config``, or when ``set_evaluator`` was given an explicit ``evaluator_config``). Any existing pool is shut down, because its workers were initialized from the previous context and would otherwise keep using a stale evaluator. Args: sampler: The ThompsonSampler whose ``evaluate`` the workers run. evaluator_config: Picklable evaluator config used to rebuild the evaluator inside each worker. If None, the context is cleared. """ self.close() self._worker_context = ( None if evaluator_config is None else (sampler, evaluator_config) )
def _ensure_pool(self): """Create the process pool if it doesn't exist. Always uses the ``spawn`` start method (see the module docstring for why ``fork`` deadlocks on Polars). """ if self.processes > 1 and self._pool is None: ctx = multiprocessing.get_context("spawn") if self._worker_context is not None: self._pool = ctx.Pool( self.processes, initializer=_init_worker, initargs=self._worker_context, ) else: # No config to rebuild from; workers get whatever the parent # sends them. Fine for picklable evaluators, fatal for SWIG. self._pool = ctx.Pool(self.processes)
[docs] def close(self): """Close the process pool if it exists.""" if self._pool is not None: self._pool.close() self._pool.join() self._pool = None
[docs] def evaluate_batch(self, evaluate_fn: Callable, compound_list: List[Any]) -> List[Any]: """ Evaluate a batch of compounds in parallel. Args: evaluate_fn: Function that takes a compound representation (e.g. list of reagent indices) and returns [score, smiles, name]. Used directly when running sequentially, and as the fallback task function when no worker context has been bound. compound_list: List of compound representations to evaluate Returns: List of evaluation results [score, smiles, name] for each compound Raises: TypeError: if ``processes > 1`` with no worker context bound and the evaluator cannot be pickled. The message explains how to fix it. """ if not compound_list: return [] # Sequential evaluation for single process if self.processes <= 1: return [evaluate_fn(compound) for compound in compound_list] self._ensure_pool() # Preferred path: workers already hold their own evaluator. if self._worker_context is not None: return self._pool.map(_worker_evaluate, compound_list) # Fallback path: ship the bound method. Works for picklable evaluators # (Lookup, FP, MW, DB); fails loudly and usefully for SWIG-backed ones. try: return self._pool.map(evaluate_fn, compound_list) except TypeError as exc: if "pickle" not in str(exc).lower(): raise raise TypeError( "Cannot run this evaluator with processes > 1: it holds an " "object that cannot be pickled (OpenEye OEDock and ROCS shape " "objects are SWIG-wrapped C++ objects and are never " "picklable).\n\n" "Fix: build the sampler with ThompsonSampler.from_config(config), " "or pass the evaluator's config explicitly via " "sampler.set_evaluator(evaluator, evaluator_config=...). Either " "lets each worker construct its own evaluator instead of " "receiving one over the pipe." ) from exc
def __del__(self): """Cleanup: close pool when evaluator is garbage collected.""" self.close()