Skip to content

Commit 4141297

Browse files
committed
Allow free-threaded parallel sampling
1 parent 45f032d commit 4141297

8 files changed

Lines changed: 427 additions & 21 deletions

File tree

pymc/model/core.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
# limitations under the License.
1414
from __future__ import annotations
1515

16+
import copy
1617
import functools
1718
import sys
1819
import threading
@@ -275,6 +276,26 @@ def get_extra_values(self):
275276

276277
return {var.name: self._extra_vars_shared[var.name].get_value() for var in self._extra_vars}
277278

279+
def fork(self) -> ValueGradFunction:
280+
"""Return an independent copy for concurrent (multi-thread) use.
281+
282+
The copy shares the compiled code and any read-only inputs (e.g. observed
283+
data) with the original, but gets its own input/output storage and its own
284+
``extra_vars`` shared variables. This makes it safe to call and to
285+
``set_extra_values`` on from a different thread than the original.
286+
"""
287+
new = copy.copy(self)
288+
swap = {}
289+
new._extra_vars_shared = {}
290+
for name, shared in self._extra_vars_shared.items():
291+
value = shared.get_value(borrow=False)
292+
fresh = pytensor.shared(value, shared.name, shape=value.shape)
293+
new._extra_vars_shared[name] = fresh
294+
swap[shared] = fresh
295+
new._pytensor_function = self._pytensor_function.copy(share_memory=False, swap=swap or None)
296+
new._extra_are_set = False
297+
return new
298+
278299
def __call__(self, grad_vars, *, extra_vars=None):
279300
if extra_vars is not None:
280301
self.set_extra_values(extra_vars)

pymc/sampling/mcmc.py

Lines changed: 188 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,12 @@
2121
import pickle
2222
import re
2323
import sys
24+
import threading
2425
import time
2526
import warnings
2627

2728
from collections.abc import Callable, Iterator, Mapping, Sequence
29+
from concurrent.futures import ThreadPoolExecutor
2830
from typing import (
2931
TYPE_CHECKING,
3032
Any,
@@ -67,7 +69,13 @@
6769
default_progress_theme,
6870
)
6971
from pymc.pytensorf import resolve_backend_compile_kwargs
70-
from pymc.sampling.parallel import Draw, _cpu_count, _initialize_multiprocessing_context
72+
from pymc.sampling.parallel import (
73+
THREAD_CONTEXT,
74+
Draw,
75+
_cpu_count,
76+
_initialize_multiprocessing_context,
77+
_initialize_parallel_context,
78+
)
7179
from pymc.sampling.population import _sample_population
7280
from pymc.stats.convergence import (
7381
log_warning_stats,
@@ -745,9 +753,11 @@ def sample(
745753
the ``draw.chain`` argument can be used to determine which of the active chains the sample
746754
is drawn from.
747755
Sampling can be interrupted by throwing a ``KeyboardInterrupt`` in the callback.
748-
mp_ctx : multiprocessing.context.BaseContent
749-
A multiprocessing context for parallel sampling.
750-
See multiprocessing documentation for details.
756+
mp_ctx : multiprocessing.context.BaseContext or "thread", optional
757+
A multiprocessing context for parallel sampling; see the multiprocessing
758+
documentation for details. Pass ``"thread"`` to run chains as threads in a
759+
single process instead, which only runs in parallel on a free-threaded
760+
(no-GIL) interpreter and is selected automatically there when unset.
751761
model : Model (optional if in ``with`` context)
752762
Model to sample from. The model needs to have free random variables.
753763
backend: str, optional.
@@ -878,9 +888,9 @@ def sample(
878888
chains = max(2, cores)
879889

880890
compile_kwargs = resolve_backend_compile_kwargs(backend, compile_kwargs)
881-
mp_ctx = _initialize_multiprocessing_context(
882-
mp_ctx, mode=compile_kwargs.get("mode"), quiet=quiet
883-
)
891+
# `mp_ctx` may resolve to the THREAD_CONTEXT sentinel (free-threaded builds, or
892+
# an explicit mp_ctx="thread"): run the chains as threads instead of processes.
893+
mp_ctx = _initialize_parallel_context(mp_ctx, mode=compile_kwargs.get("mode"), quiet=quiet)
884894
joined_blas_limiter, cores, num_blas_cores_per_worker = setup_cores_blas_cores(
885895
blas_cores, chains, cores, mp_ctx
886896
)
@@ -1107,6 +1117,11 @@ def sample(
11071117
)
11081118

11091119
parallel = cores > 1 and chains > 1 and not has_population_samplers
1120+
# When the parallel context resolved to threads (free-threaded build, or an
1121+
# explicit mp_ctx="thread"), run the chains as threads: one shared model +
1122+
# compiled code, no pickling. Only attempted when the step method supports
1123+
# fork(); otherwise we fall back to multiprocessing below.
1124+
threaded = parallel and mp_ctx == THREAD_CONTEXT
11101125
# At some point it was decided that PyMC should not set a global seed by default,
11111126
# unless the user specified a seed. This is a symptom of the fact that PyMC samplers
11121127
# are built around global seeding. This branch makes sure we maintain this unspoken
@@ -1121,13 +1136,34 @@ def sample(
11211136
sample_args["rngs"] = rngs
11221137

11231138
t_start = time.time()
1139+
sampled = False
11241140
with _quiet_logging(quiet):
1125-
if parallel:
1141+
if threaded:
1142+
if not quiet:
1143+
_log.info(f"Threaded sampling ({chains} chains in {cores} threads)")
1144+
_print_step_hierarchy(step)
1145+
try:
1146+
with joined_blas_limiter():
1147+
_thread_sample(**sample_args)
1148+
sampled = True
1149+
except NotImplementedError as e:
1150+
if not quiet:
1151+
_log.warning(
1152+
f"Step method does not support threaded sampling ({e}); "
1153+
"falling back to multiprocessing."
1154+
)
1155+
# Resolve a real process context in place of the "thread" sentinel.
1156+
mp_ctx = _initialize_multiprocessing_context(
1157+
None, mode=compile_kwargs.get("mode"), quiet=quiet
1158+
)
1159+
parallel_args["mp_ctx"] = mp_ctx
1160+
if parallel and not sampled:
11261161
if not quiet:
11271162
_log.info(f"Multiprocess sampling ({chains} chains in {cores} jobs)")
11281163
_print_step_hierarchy(step)
11291164
try:
11301165
_mp_sample(**sample_args, **parallel_args)
1166+
sampled = True
11311167
except pickle.PickleError:
11321168
if not quiet:
11331169
_log.warning("Could not pickle model, sampling singlethreaded.")
@@ -1140,7 +1176,7 @@ def sample(
11401176
_log.warning("Could not pickle model, sampling singlethreaded.")
11411177
_log.debug("Pickling error:", exc_info=True)
11421178
parallel = False
1143-
if not parallel:
1179+
if not sampled:
11441180
if has_population_samplers:
11451181
if not quiet:
11461182
_log.info(f"Population sampling ({chains} chains)")
@@ -1198,11 +1234,12 @@ def setup_cores_blas_cores(
11981234
num_blas_cores_per_worker = None
11991235

12001236
elif isinstance(blas_cores, int):
1201-
if mp_ctx.get_start_method() == "fork":
1237+
if mp_ctx != THREAD_CONTEXT and mp_ctx.get_start_method() == "fork":
12021238
# https://github.com/pymc-devs/pymc/issues/7354
12031239
joined_blas_limiter = contextlib.nullcontext
12041240
else:
1205-
1241+
# Threads share one process, so a single process-wide BLAS limiter
1242+
# (rather than a per-worker split) applies to all chains.
12061243
def joined_blas_limiter():
12071244
return threadpool_limits(limits=blas_cores)
12081245

@@ -1420,6 +1457,111 @@ def _sample_many(
14201457
return
14211458

14221459

1460+
def _thread_sample(
1461+
*,
1462+
draws: int,
1463+
chains: int,
1464+
cores: int,
1465+
traces: Sequence[IBaseTrace],
1466+
start: Sequence[PointType],
1467+
rngs: Sequence[np.random.Generator],
1468+
step: Step,
1469+
callback: SamplingIteratorCallback | None = None,
1470+
**kwargs,
1471+
):
1472+
"""Sample all chains concurrently as threads (free-threaded / no-GIL builds).
1473+
1474+
Each chain runs on an independent ``step.fork(rng)`` so chains never share
1475+
mutable compiled-function storage, adaptation state, or RNG. This is the
1476+
in-process counterpart of :func:`_mp_sample`: it shares one model, one set of
1477+
compiled thunks, and the observed data across chains instead of duplicating
1478+
them per process.
1479+
1480+
At most ``cores`` chains run at once (via a thread pool of that size), matching
1481+
the multiprocessing path. ``Ctrl-C`` sets a stop flag that the workers check
1482+
between draws and unwind cooperatively -- worker threads never receive the
1483+
``KeyboardInterrupt`` themselves.
1484+
1485+
Raises ``NotImplementedError`` if the step method has not implemented
1486+
``fork``; the caller catches this and falls back to multiprocessing.
1487+
1488+
Parameters
1489+
----------
1490+
draws : int
1491+
Total number of iterations per chain, including tuning.
1492+
chains : int
1493+
Number of chains to sample.
1494+
cores : int
1495+
Maximum number of chains to sample concurrently.
1496+
traces : list of backends
1497+
One trace per chain; each is written only by its own thread.
1498+
start : list
1499+
Starting point for each chain.
1500+
rngs : list of Generators
1501+
One independent Generator per chain.
1502+
step : Step
1503+
The step method for chain 0; the other chains use ``step.fork(rng)``.
1504+
"""
1505+
tune = kwargs.get("tune", 0)
1506+
model = kwargs.get("model", None)
1507+
1508+
# Fork one independent step per chain up front, in the main thread, so all
1509+
# compilation/copying happens before any worker starts -- and so a step that
1510+
# can't fork raises here (not inside a thread) for a clean fallback.
1511+
steps = [step] + [step.fork(rngs[i]) for i in range(1, chains)]
1512+
1513+
progress_manager = MCMCProgressBarManager(
1514+
step_method=step,
1515+
chains=chains,
1516+
draws=draws - tune,
1517+
tune=tune,
1518+
progressbar=kwargs.get("progressbar", True),
1519+
progressbar_theme=kwargs.get("progressbar_theme", default_progress_theme),
1520+
)
1521+
1522+
errors: list[BaseException | None] = [None] * chains
1523+
progress_lock = threading.Lock()
1524+
stop_event = threading.Event()
1525+
1526+
def _worker(i: int) -> None:
1527+
if stop_event.is_set():
1528+
return
1529+
try:
1530+
_sample(
1531+
chain=i,
1532+
rng=rngs[i],
1533+
start=start[i],
1534+
draws=draws,
1535+
step=steps[i],
1536+
trace=traces[i],
1537+
tune=tune,
1538+
model=model,
1539+
callback=callback,
1540+
progress_manager=progress_manager,
1541+
progress_lock=progress_lock,
1542+
stop_event=stop_event,
1543+
)
1544+
except BaseException as e:
1545+
errors[i] = e
1546+
1547+
with progress_manager, ThreadPoolExecutor(max_workers=cores) as executor:
1548+
futures = [executor.submit(_worker, i) for i in range(chains)]
1549+
try:
1550+
for future in futures:
1551+
future.result()
1552+
except KeyboardInterrupt:
1553+
# Stop running workers at their next draw and drop chains that haven't
1554+
# started; the pool's shutdown waits for the in-flight ones to unwind.
1555+
stop_event.set()
1556+
for future in futures:
1557+
future.cancel()
1558+
1559+
for e in errors:
1560+
if e is not None:
1561+
raise e
1562+
return
1563+
1564+
14231565
def _sample(
14241566
*,
14251567
chain: int,
@@ -1432,11 +1574,15 @@ def _sample(
14321574
model: Model | None = None,
14331575
callback=None,
14341576
progress_manager: MCMCProgressBarManager,
1577+
progress_lock: threading.Lock | None = None,
1578+
stop_event: threading.Event | None = None,
14351579
**kwargs,
14361580
) -> None:
1437-
"""Sample one chain (singleprocess).
1581+
"""Sample one chain (single process, in the calling thread).
14381582
1439-
Multiple step methods are supported via compound step methods.
1583+
Multiple step methods are supported via compound step methods. Used both by
1584+
the sequential sampler (:func:`_sample_many`) and, once per thread, by the
1585+
threaded sampler (:func:`_thread_sample`).
14401586
14411587
Parameters
14421588
----------
@@ -1458,7 +1604,23 @@ def _sample(
14581604
PyMC model. If None, the model is taken from the current context.
14591605
progress_manager: ProgressBarManager
14601606
Helper class used to handle progress bar styling and updates
1607+
progress_lock : threading.Lock, optional
1608+
Serializes progress-bar updates when several chains share one
1609+
``progress_manager`` across threads. ``None`` (the sequential path) skips
1610+
locking entirely.
1611+
stop_event : threading.Event, optional
1612+
Cooperative cancellation flag checked between draws; when set, the chain
1613+
stops early without marking its bar complete. Used by threaded sampling to
1614+
unwind all chains on ``Ctrl-C``.
14611615
"""
1616+
1617+
def update(**kwargs):
1618+
if progress_lock is None:
1619+
progress_manager.update(**kwargs)
1620+
else:
1621+
with progress_lock:
1622+
progress_manager.update(**kwargs)
1623+
14621624
sampling_gen = _iter_sample(
14631625
draws=draws,
14641626
step=step,
@@ -1472,17 +1634,22 @@ def _sample(
14721634
)
14731635
try:
14741636
for it, stats in enumerate(sampling_gen):
1475-
progress_manager.update(
1476-
chain_idx=chain, is_last=False, draw=it, stats=stats, tuning=it < tune
1477-
)
1478-
1479-
if not progress_manager.combined_progress or chain == progress_manager.chains - 1:
1480-
progress_manager.update(
1481-
chain_idx=chain, is_last=True, draw=it, stats=stats, tuning=False
1482-
)
1637+
update(chain_idx=chain, is_last=False, draw=it, stats=stats, tuning=it < tune)
1638+
if stop_event is not None and stop_event.is_set():
1639+
break
1640+
else:
1641+
# Reached only when the chain drew every sample (no early stop). In
1642+
# combined-progress mode a single bar tracks all chains, so only the
1643+
# last chain marks it complete; otherwise each chain finalizes its own.
1644+
if not progress_manager.combined_progress or chain == progress_manager.chains - 1:
1645+
update(chain_idx=chain, is_last=True, draw=it, stats=stats, tuning=False)
14831646

14841647
except KeyboardInterrupt:
14851648
pass
1649+
finally:
1650+
# Close the generator (and thus the trace) deterministically, whether we
1651+
# exhausted it, broke out on the stop flag, or errored.
1652+
sampling_gen.close()
14861653

14871654

14881655
def _iter_sample(

pymc/sampling/parallel.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import multiprocessing
1919
import multiprocessing.sharedctypes
2020
import platform
21+
import sys
2122
import time
2223
import traceback
2324
import warnings
@@ -85,6 +86,39 @@ def rebuild_exc(exc, tb):
8586
return exc
8687

8788

89+
#: Sentinel returned by `_initialize_parallel_context` to request thread-based
90+
#: (rather than process-based) multi-chain sampling.
91+
THREAD_CONTEXT = "thread"
92+
93+
94+
def _initialize_parallel_context(
95+
mp_ctx: str | multiprocessing.context.BaseContext | None,
96+
*,
97+
mode=None,
98+
quiet: bool = False,
99+
) -> multiprocessing.context.BaseContext | str:
100+
"""Resolve how to parallelize chains: threads or a multiprocessing context.
101+
102+
Returns the :data:`THREAD_CONTEXT` sentinel to run chains as threads -- when
103+
the user explicitly passes ``mp_ctx="thread"``, or, on a free-threaded (no-GIL)
104+
interpreter, when they have not requested a specific context. Either way we
105+
respect it: the request was explicit, or we concluded threading was available.
106+
Otherwise it composes :func:`_initialize_multiprocessing_context` for a real
107+
process context.
108+
"""
109+
gil_enabled = getattr(sys, "_is_gil_enabled", lambda: True)()
110+
if mp_ctx == THREAD_CONTEXT or (mp_ctx is None and not gil_enabled):
111+
if mp_ctx == THREAD_CONTEXT and gil_enabled and not quiet:
112+
warnings.warn(
113+
"mp_ctx='thread' was requested but the interpreter's GIL is "
114+
"enabled, so chains will not actually run in parallel.",
115+
UserWarning,
116+
stacklevel=2,
117+
)
118+
return THREAD_CONTEXT
119+
return _initialize_multiprocessing_context(mp_ctx, mode=mode, quiet=quiet)
120+
121+
88122
def _initialize_multiprocessing_context(
89123
mp_ctx: str | multiprocessing.context.BaseContext | None,
90124
*,

0 commit comments

Comments
 (0)