Coverage for cosmolayer/store/parallel.py: 91%
22 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-26 00:09 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-26 00:09 +0000
1"""Threaded execution over molecule-sized chunks of segment- or atom-level
2arrays.
4Each pass splits *molecules* (not segments or atoms) into contiguous
5ranges so threads own disjoint molecules and need no locking.
6"""
8import os
9from collections.abc import Callable, Iterator
10from concurrent.futures import ThreadPoolExecutor, as_completed
11from contextlib import nullcontext
13import threadpoolctl
16def resolve_num_threads(num_threads: int | None) -> int:
17 """Resolve a possibly-unset thread count to a concrete positive value.
19 Parameters
20 ----------
21 num_threads : int | None
22 Requested number of threads, or None to mean "every available CPU
23 core".
25 Returns
26 -------
27 int
28 ``num_threads`` if given, else ``os.cpu_count()`` (falling back to
29 1 if that returns None, e.g. in a constrained container).
30 """
31 if num_threads is not None:
32 return num_threads
33 return os.cpu_count() or 1
36def molecule_chunks(num_items: int, num_threads: int) -> Iterator[tuple[int, int]]:
37 """Split ``range(num_items)`` into up to ``num_threads`` contiguous,
38 roughly equal chunks.
40 Parameters
41 ----------
42 num_items : int
43 Total number of molecules (or other items) to split.
44 num_threads : int
45 Number of chunks to aim for. Fewer are yielded if
46 ``num_items < num_threads``.
48 Yields
49 ------
50 tuple[int, int]
51 ``(start, stop)`` bounds of each chunk, usable as a Python slice.
52 """
53 if num_items == 0:
54 return
55 chunk_size = (num_items + num_threads - 1) // num_threads
56 for start in range(0, num_items, chunk_size):
57 yield start, min(start + chunk_size, num_items)
60def run_in_threads(
61 fn: Callable[[int, int], None],
62 num_items: int,
63 *,
64 num_threads: int | None = None,
65 limit_blas: bool = False,
66) -> None:
67 """Run ``fn(start, stop)`` once per molecule chunk, across threads.
69 Chunks cover disjoint molecules, so ``fn`` may write only to its own
70 ``[start, stop)`` range.
72 Parameters
73 ----------
74 fn : Callable[[int, int], None]
75 Called once per chunk with that chunk's molecule bounds.
76 Exceptions in a thread are re-raised in the caller.
77 num_items : int
78 Number of molecules to split across threads.
79 num_threads : int | None, optional
80 Thread count. ``None`` (default) uses every CPU core.
81 limit_blas : bool, optional
82 If True, cap BLAS to 1 thread for the duration of the call, so
83 numpy matrix products do not oversubscribe the CPU.
84 """
85 num_threads = resolve_num_threads(num_threads)
86 limiter = threadpoolctl.threadpool_limits(limits=1) if limit_blas else nullcontext()
87 with limiter, ThreadPoolExecutor(max_workers=num_threads) as executor:
88 futures = [
89 executor.submit(fn, start, stop)
90 for start, stop in molecule_chunks(num_items, num_threads)
91 ]
92 for future in as_completed(futures):
93 future.result()