Skip to content

Commit 6cc0794

Browse files
authored
Merge pull request #152 from eurunuela/fix/n-jobs-parallel-scheduler
Wire n_jobs to Dask threaded scheduler in all voxel-wise solvers
2 parents 03950c1 + b5810a7 commit 6cc0794

5 files changed

Lines changed: 82 additions & 16 deletions

File tree

docs/conf.py

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@
5050
"sphinx.ext.mathjax",
5151
"sphinx.ext.napoleon",
5252
"sphinx.ext.todo",
53-
"sphinx_charts.charts",
5453
"sphinx_gallery.load_style",
5554
"sphinxcontrib.jquery",
5655
]
@@ -75,8 +74,6 @@
7574
# nb_render_plugin = "plotly"
7675

7776

78-
79-
8077
# if LooseVersion(sphinx.__version__) < LooseVersion("1.4"):
8178
# extensions.append("sphinx.ext.pngmath")
8279
# else:
@@ -192,9 +189,7 @@ def setup(app):
192189
# The following is used by sphinx.ext.linkcode to provide links to github
193190
linkcode_resolve = make_linkcode_resolve(
194191
"pySPFM",
195-
"https://github.com/ParadigmFreeMapping/"
196-
"pySPFM/blob/{revision}/"
197-
"{package}/{path}#L{lineno}",
192+
"https://github.com/ParadigmFreeMapping/pySPFM/blob/{revision}/{package}/{path}#L{lineno}",
198193
)
199194

200195
# Example configuration for intersphinx: refer to the Python standard library.

pySPFM/decomposition.py

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,33 @@ def _generate_hrf_matrix(self, n_scans):
103103
hrf_obj.generate_hrf(tr=self.tr, n_scans=n_scans)
104104
return hrf_obj.hrf_
105105

106+
def _dask_compute(self, futures):
107+
"""Run dask compute with the scheduler selected by ``n_jobs``.
108+
109+
Uses the synchronous scheduler when ``n_jobs=1`` (default, safe for
110+
all environments) and the threaded scheduler otherwise. Because the
111+
per-voxel solvers (LARS, FISTA) call into numpy/scipy C extensions
112+
that release the GIL, threads provide real parallelism here.
113+
114+
Parameters
115+
----------
116+
futures : list of dask.delayed
117+
Delayed objects to compute.
118+
119+
Returns
120+
-------
121+
results : list
122+
Computed results in the same order as ``futures``.
123+
"""
124+
if self.n_jobs == 0 or self.n_jobs < -1:
125+
raise ValueError(
126+
f"n_jobs must be -1 (use all CPUs) or a positive integer >= 1, got {self.n_jobs!r}"
127+
)
128+
if self.n_jobs == 1:
129+
return compute(futures, scheduler="synchronous")[0]
130+
num_workers = None if self.n_jobs == -1 else self.n_jobs
131+
return compute(futures, scheduler="threads", num_workers=num_workers)[0]
132+
106133
@abstractmethod
107134
def fit(self, X, y=None):
108135
"""Fit the deconvolution model.
@@ -378,7 +405,7 @@ def _fit_lars(self, X, n_scans, n_voxels):
378405
)
379406
futures.append(fut)
380407

381-
results = compute(futures, scheduler="synchronous")[0]
408+
results = self._dask_compute(futures)
382409

383410
for vox_idx in range(n_voxels):
384411
self.coef_[:, vox_idx] = np.squeeze(results[vox_idx][0])
@@ -403,7 +430,7 @@ def _fit_fista(self, X, n_scans, n_voxels):
403430
)
404431
futures.append(fut)
405432

406-
results = compute(futures, scheduler="synchronous")[0]
433+
results = self._dask_compute(futures)
407434

408435
for vox_idx in range(n_voxels):
409436
self.coef_[:, vox_idx] = np.squeeze(results[vox_idx][0])
@@ -628,7 +655,7 @@ def fit(self, X, y=None):
628655
)
629656
futures.append(fut)
630657

631-
results = compute(futures, scheduler="synchronous")[0]
658+
results = self._dask_compute(futures)
632659

633660
for vox_idx in range(n_voxels):
634661
self.coef_[:, vox_idx] = np.squeeze(results[vox_idx][0])
@@ -806,7 +833,7 @@ def fit(self, X, y=None):
806833
)
807834
futures.append(fut)
808835

809-
results = compute(futures, scheduler="synchronous")[0]
836+
results = self._dask_compute(futures)
810837

811838
for vox_idx in range(n_voxels):
812839
self.selection_frequency_[:, vox_idx] = np.squeeze(results[vox_idx])

pySPFM/tests/test_decomposition.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,3 +484,48 @@ def test_threshold_binary_selection(self, sample_data):
484484
# coef_ should only contain 0s and 1s
485485
unique_vals = np.unique(model.coef_)
486486
assert set(unique_vals).issubset({0.0, 1.0})
487+
488+
489+
class TestDaskCompute:
490+
"""Tests for _BaseDeconvolution._dask_compute scheduler selection."""
491+
492+
def test_n_jobs_1_uses_synchronous(self):
493+
"""n_jobs=1 must select the synchronous scheduler."""
494+
from unittest.mock import patch
495+
496+
from pySPFM import SparseDeconvolution
497+
498+
model = SparseDeconvolution(tr=2.0, n_jobs=1)
499+
with patch("pySPFM.decomposition.compute", return_value=([],)) as mock_compute:
500+
model._dask_compute([])
501+
mock_compute.assert_called_once_with([], scheduler="synchronous")
502+
503+
def test_n_jobs_gt1_uses_threaded(self):
504+
"""n_jobs=2 must select the threaded scheduler with num_workers=2."""
505+
from unittest.mock import patch
506+
507+
from pySPFM import SparseDeconvolution
508+
509+
model = SparseDeconvolution(tr=2.0, n_jobs=2)
510+
with patch("pySPFM.decomposition.compute", return_value=([],)) as mock_compute:
511+
model._dask_compute([])
512+
mock_compute.assert_called_once_with([], scheduler="threads", num_workers=2)
513+
514+
def test_n_jobs_minus1_uses_all_cpus(self):
515+
"""n_jobs=-1 must select the threaded scheduler with num_workers=None."""
516+
from unittest.mock import patch
517+
518+
from pySPFM import SparseDeconvolution
519+
520+
model = SparseDeconvolution(tr=2.0, n_jobs=-1)
521+
with patch("pySPFM.decomposition.compute", return_value=([],)) as mock_compute:
522+
model._dask_compute([])
523+
mock_compute.assert_called_once_with([], scheduler="threads", num_workers=None)
524+
525+
def test_invalid_n_jobs_raises(self):
526+
"""n_jobs=0 must raise ValueError with a message mentioning n_jobs."""
527+
from pySPFM import SparseDeconvolution
528+
529+
model = SparseDeconvolution(tr=2.0, n_jobs=0)
530+
with pytest.raises(ValueError, match="n_jobs"):
531+
model._dask_compute([])

pySPFM/tests/test_integration.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@
88
import shutil
99
import tarfile
1010
from gzip import GzipFile
11+
from importlib.resources import files
1112

1213
import pytest
13-
from pkg_resources import resource_filename
1414

1515

1616
def extract_test_data(tarball_path, outpath):
@@ -292,7 +292,7 @@ def test_integration_auc_to_estimates(
292292
assert ret.success
293293

294294
# compare the generated output files
295-
fn = resource_filename("pySPFM", "tests/data/auc_to_estimates_outputs.txt")
295+
fn = str(files("pySPFM") / "tests" / "data" / "auc_to_estimates_outputs.txt")
296296
check_integration_outputs(fn, out_dir, "auc_to_estimates")
297297

298298
############################
@@ -329,7 +329,7 @@ def test_integration_auc_to_estimates(
329329
assert ret.success
330330

331331
# compare the generated output files
332-
fn = resource_filename("pySPFM", "tests/data/auc_to_estimates_outputs.txt")
332+
fn = str(files("pySPFM") / "tests" / "data" / "auc_to_estimates_outputs.txt")
333333
check_integration_outputs(fn, out_dir, "auc_to_estimates")
334334

335335
############################
@@ -362,7 +362,7 @@ def test_integration_auc_to_estimates(
362362
assert ret.success
363363

364364
# compare the generated output files
365-
fn = resource_filename("pySPFM", "tests/data/auc_to_estimates_outputs.txt")
365+
fn = str(files("pySPFM") / "tests" / "data" / "auc_to_estimates_outputs.txt")
366366
check_integration_outputs(fn, out_dir, "auc_to_estimates")
367367

368368
############################
@@ -395,7 +395,7 @@ def test_integration_auc_to_estimates(
395395
assert ret.success
396396

397397
# compare the generated output files
398-
fn = resource_filename("pySPFM", "tests/data/auc_to_estimates_outputs.txt")
398+
fn = str(files("pySPFM") / "tests" / "data" / "auc_to_estimates_outputs.txt")
399399
check_integration_outputs(fn, out_dir, "auc_to_estimates")
400400

401401

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,6 @@ doc = [
6363
"sphinx_design",
6464
"sphinx_gallery",
6565
"sphinx-book-theme",
66-
"sphinx_charts",
6766
"sphinxcontrib-jquery",
6867
]
6968

0 commit comments

Comments
 (0)