2121import pickle
2222import re
2323import sys
24+ import threading
2425import time
2526import warnings
2627
2728from collections .abc import Callable , Iterator , Mapping , Sequence
29+ from concurrent .futures import ThreadPoolExecutor
2830from typing import (
2931 TYPE_CHECKING ,
3032 Any ,
6769 default_progress_theme ,
6870)
6971from 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+ )
7179from pymc .sampling .population import _sample_population
7280from 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+
14231565def _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
14881655def _iter_sample (
0 commit comments