Skip to content

Commit 26240b5

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

12 files changed

Lines changed: 551 additions & 23 deletions

File tree

.github/workflows/tests.yml

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,53 @@ 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+
# Intentionally leave PYTHON_GIL unset. Setting it to 0 would force the GIL
365+
# off and hide a dependency that fails to support free-threading; leaving it
366+
# unset lets such a dependency re-enable the GIL, which the conftest
367+
# pytest_sessionfinish hook asserts against.
368+
defaults:
369+
run:
370+
shell: bash -leo pipefail {0}
371+
steps:
372+
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
373+
with:
374+
persist-credentials: false
375+
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
376+
with:
377+
python-version: "3.14t"
378+
- name: Install pymc and test dependencies
379+
run: |
380+
python -m pip install --upgrade pip
381+
# Install from PyPI (free-threaded wheels), not conda-forge. numba is
382+
# included as the free-threading-safe PyTensor backend.
383+
pip install -e . numba pytest pytest-cov
384+
# Free-threaded sampling needs the (still unreleased) PyTensor
385+
# free-threading support (pymc-devs/pytensor#2286); track its branch
386+
# until it is released.
387+
pip install "pytensor @ git+https://github.com/ricardoV94/pytensor@free_threaded"
388+
python --version
389+
python -c "import sys, sysconfig; print('Py_GIL_DISABLED =', sysconfig.get_config_var('Py_GIL_DISABLED'), '| gil_enabled =', sys._is_gil_enabled())"
390+
pip list
391+
- name: Run tests
392+
run: |
393+
python -m pytest -vv --durations=25 $TEST_SUBSET
394+
348395
float32:
349396
needs: changes
350397
if: ${{ needs.changes.outputs.changes == 'true' }}
@@ -395,13 +442,14 @@ jobs:
395442
all_tests:
396443
if: ${{ always() }}
397444
runs-on: ubuntu-latest
398-
needs: [changes, ubuntu, windows, macos, alternative_backends, float32]
445+
needs: [changes, ubuntu, windows, macos, alternative_backends, free_threading, float32]
399446
steps:
400447
- name: Check build matrix status
401448
if: ${{ needs.changes.outputs.changes == 'true' &&
402449
( needs.ubuntu.result != 'success' ||
403450
needs.windows.result != 'success' ||
404451
needs.macos.result != 'success' ||
405452
needs.alternative_backends.result != 'success' ||
453+
needs.free_threading.result != 'success' ||
406454
needs.float32.result != 'success' ) }}
407455
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)