Source code for pyphi.parallel.backends.local_thread

"""Thread-pool scheduler.

Workers run in the parent process, so they share the parent's global
config and caches. Snapshot apply is a no-op (the parent's live globals
already reflect the captured snapshot).

Best suited for free-threaded Python (3.13t+) where multiple OS threads can
execute Python concurrently. Under standard CPython the GIL limits the
throughput benefit but the scheduler still avoids pickle overhead and is
useful for IO-bound work.
"""

from __future__ import annotations

import math
import os
from collections.abc import Callable
from collections.abc import Iterable
from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import as_completed
from typing import Any

from pyphi.parallel.backends.progress import LocalProgressBar
from pyphi.parallel.scheduler import ChunkingPolicy
from pyphi.parallel.scheduler import ProgressPolicy
from pyphi.parallel.scheduler import ShortcircuitPolicy


[docs] class LocalThreadScheduler: """Scheduler backed by ``concurrent.futures.ThreadPoolExecutor``.""" @property def supports_shared_state(self) -> bool: return True def map_reduce( self, fn: Callable[..., Any], items: Iterable[Any], *more_items: Iterable[Any], reducer: Callable[[Iterable[Any]], Any] = list, config_snapshot: Any | None = None, chunking: Any = None, progress: Any = None, shortcircuit: Any = None, ordered: bool = False, map_kwargs: dict[str, Any] | None = None, ) -> Any: # Threads share the parent's globals, so there is no snapshot to apply. del config_snapshot chunking = chunking or ChunkingPolicy() progress = progress or ProgressPolicy() shortcircuit = shortcircuit or ShortcircuitPolicy() map_kwargs = map_kwargs or {} # Mark the parent PID so the snapshot-apply hook short-circuits when # called in-thread (threads share parent's globals). Restore the # previous value afterwards: a permanently latched PID would disable # snapshot installs for the rest of this process's life — silently # stale config when this process is itself a loky worker that later # receives a new parent snapshot. from pyphi.parallel.backends import local_process previous_parent_pid = local_process._PARENT_PID local_process._PARENT_PID = os.getpid() try: return self._map_reduce( fn, items, *more_items, reducer=reducer, chunking=chunking, progress=progress, shortcircuit=shortcircuit, ordered=ordered, map_kwargs=map_kwargs, ) finally: local_process._PARENT_PID = previous_parent_pid def _map_reduce( self, fn: Callable[..., Any], items: Iterable[Any], *more_items: Iterable[Any], reducer: Callable[[Iterable[Any]], Any], chunking: Any, progress: Any, shortcircuit: Any, ordered: bool, map_kwargs: dict[str, Any], ) -> Any: from pyphi.parallel import get_num_processes num_workers = get_num_processes() materialized = [list(it) for it in (items, *more_items)] if not materialized or not materialized[0]: return reducer([]) # Updates happen only in this (collecting) thread, never in workers, # so the bar needs no locking. progress_bar = ( LocalProgressBar( total=progress.total if progress.total is not None else len(materialized[0]), desc=progress.desc, ) if progress.enabled else None ) try: if len(materialized[0]) < chunking.sequential_threshold: results: list[Any] = [] for args in zip(*materialized, strict=False): value = fn(*args, **map_kwargs) results.append(value) if progress_bar is not None: progress_bar.update(1) if shortcircuit.func(value): shortcircuit.fire(results) break return reducer(results) # Group items into chunks so each future carries many items: # per-item futures pay dispatch overhead per item and ignore the # caller's chunking policy. Without an explicit chunksize, items # are split evenly across the workers. from pyphi.parallel.backends.local_process import _process_chunk from pyphi.parallel.chunking import iter_chunks n = min(len(it) for it in materialized) chunksize = chunking.chunksize or math.ceil(n / num_workers) chunks = list( iter_chunks( materialized, chunksize=chunksize, num_workers=num_workers, size_func=chunking.size_func, ) ) results = [] short_circuited = False with ThreadPoolExecutor(max_workers=num_workers) as executor: futures = [ executor.submit( _process_chunk, chunk, fn, map_kwargs, shortcircuit.func ) for chunk in chunks ] # Collect in submission order when the caller asked for # original order or a short-circuit predicate is active. When # short-circuiting, the collected subset is truncated at the # first triggering result, so completion order would make that # subset — and any order-sensitive reduction over it (e.g. tie # resolution among the surviving candidates) — depend on # thread scheduling. Submission order yields the same prefix # as sequential evaluation. iterator: Iterable[Any] = ( futures if ordered or shortcircuit.active else as_completed(futures) ) # A worker exception cancels the pending futures before # propagating; otherwise the executor's shutdown would block # until every orphaned future had run to completion. try: for fut in iterator: chunk_results = fut.result() results.extend(chunk_results) if progress_bar is not None: progress_bar.update(len(chunk_results)) for value in chunk_results: if shortcircuit.func(value): short_circuited = True for remaining in futures: if not remaining.done(): remaining.cancel() shortcircuit.fire(results) break if short_circuited: break except BaseException: for remaining in futures: if not remaining.done(): remaining.cancel() raise return reducer(results) finally: if progress_bar is not None: progress_bar.close()