Skip to content

Commit f83b2aa

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

12 files changed

Lines changed: 555 additions & 23 deletions

File tree

.github/workflows/tests.yml

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,57 @@ jobs:
345345
name: Alternative backends py${{ matrix.python-version }} linker=${{ matrix.linker }}
346346
fail_ci_if_error: false
347347

348+
free_threading:
349+
needs: changes
350+
if: ${{ needs.changes.outputs.changes == 'true' }}
351+
strategy:
352+
matrix:
353+
# A small, sampling-focused subset that exercises the thread-based parallel
354+
# sampling path used on free-threaded (no-GIL) builds. The GIL-stays-disabled
355+
# check is a pytest_sessionfinish hook in tests/conftest.py.
356+
test-subset:
357+
- |
358+
tests/sampling/test_parallel.py
359+
tests/step_methods/test_compound.py
360+
fail-fast: false
361+
runs-on: ubuntu-latest
362+
env:
363+
TEST_SUBSET: ${{ matrix.test-subset }}
364+
# Disable the C backend (cxx=""). Its single-phase-init C extensions (e.g.
365+
# lazylinker_ext) re-enable the GIL on a free-threaded build; with no C
366+
# compiler PyTensor uses the free-threading-safe numba/Python backend.
367+
PYTENSOR_FLAGS: cxx=
368+
# Intentionally leave PYTHON_GIL unset. Setting it to 0 would force the GIL
369+
# off and hide a dependency that fails to support free-threading; leaving it
370+
# unset lets such a dependency re-enable the GIL, which the conftest
371+
# pytest_sessionfinish hook asserts against.
372+
defaults:
373+
run:
374+
shell: bash -leo pipefail {0}
375+
steps:
376+
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
377+
with:
378+
persist-credentials: false
379+
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
380+
with:
381+
python-version: "3.14t"
382+
- name: Install pymc and test dependencies
383+
run: |
384+
python -m pip install --upgrade pip
385+
# Install from PyPI (free-threaded wheels), not conda-forge. numba is
386+
# included as the free-threading-safe PyTensor backend.
387+
pip install -e . numba pytest pytest-cov
388+
# Free-threaded sampling needs the (still unreleased) PyTensor
389+
# free-threading support (pymc-devs/pytensor#2286); track its branch
390+
# until it is released.
391+
pip install "pytensor @ git+https://github.com/ricardoV94/pytensor@free_threaded"
392+
python --version
393+
python -c "import sys, sysconfig; print('Py_GIL_DISABLED =', sysconfig.get_config_var('Py_GIL_DISABLED'), '| gil_enabled =', sys._is_gil_enabled())"
394+
pip list
395+
- name: Run tests
396+
run: |
397+
python -m pytest -vv --durations=25 $TEST_SUBSET
398+
348399
float32:
349400
needs: changes
350401
if: ${{ needs.changes.outputs.changes == 'true' }}
@@ -395,13 +446,14 @@ jobs:
395446
all_tests:
396447
if: ${{ always() }}
397448
runs-on: ubuntu-latest
398-
needs: [changes, ubuntu, windows, macos, alternative_backends, float32]
449+
needs: [changes, ubuntu, windows, macos, alternative_backends, free_threading, float32]
399450
steps:
400451
- name: Check build matrix status
401452
if: ${{ needs.changes.outputs.changes == 'true' &&
402453
( needs.ubuntu.result != 'success' ||
403454
needs.windows.result != 'success' ||
404455
needs.macos.result != 'success' ||
405456
needs.alternative_backends.result != 'success' ||
457+
needs.free_threading.result != 'success' ||
406458
needs.float32.result != 'success' ) }}
407459
run: exit 1

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)

0 commit comments

Comments
 (0)