diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index b68cd83..a6b84ce 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -62,7 +62,7 @@ jobs: if: ${{ needs.release-please.outputs.release_created }} runs-on: ubuntu-latest permissions: - contents: write + contents: read pull-requests: write steps: - uses: actions/checkout@v4 diff --git a/abses/core/experiment.py b/abses/core/experiment.py index 44e7a55..7c58ef8 100644 --- a/abses/core/experiment.py +++ b/abses/core/experiment.py @@ -40,6 +40,7 @@ from hydra.core.hydra_config import HydraConf, HydraConfig from joblib import Parallel, delayed from omegaconf import DictConfig, OmegaConf +from omegaconf.errors import OmegaConfBaseException from tqdm.auto import tqdm from abses.core.job_manager import ExperimentManager @@ -102,8 +103,9 @@ def relative_path_from_to(from_path: Path, to_path: Path) -> Path: # Hydra ships exactly one built-in launcher, `BasicLauncher`, and it is serial. # Anything outside this prefix is a third-party launcher plugin, which we treat -# as parallel: the launchers that exist in practice (joblib, submitit, ray) all -# are, and assuming parallel only costs us a layer of nesting we skip. +# as parallel unless its own config says otherwise: the launchers that exist in +# practice (joblib, submitit, ray) all are, and assuming parallel only costs us +# a layer of nesting we skip. _SERIAL_LAUNCHER_PREFIX = "hydra._internal.core_plugins." @@ -125,7 +127,20 @@ def launcher_is_parallel(launcher: Optional[DictConfig]) -> bool: if not launcher: return False target = str(launcher.get("_target_", "")) - return bool(target) and not target.startswith(_SERIAL_LAUNCHER_PREFIX) + if not target or target.startswith(_SERIAL_LAUNCHER_PREFIX): + return False + # A plugin can still be told to run serially. `n_jobs` is joblib's knob and + # joblib is the only launcher that spells it that way; `n_jobs: 1` selects + # joblib's sequential backend, which runs every job in the calling process + # exactly like BasicLauncher does. Other values -- including the -1 default + # and an absent key -- leave the launcher concurrent. + try: + n_jobs = OmegaConf.select(launcher, "n_jobs", default=None) + except OmegaConfBaseException: + # An unreadable value tells us nothing; keep the optimistic default + # rather than aborting the run from inside a predicate. + return True + return n_jobs != 1 def run_single( diff --git a/tests/core/test_experiment.py b/tests/core/test_experiment.py index 17a6e23..be7dda6 100644 --- a/tests/core/test_experiment.py +++ b/tests/core/test_experiment.py @@ -130,6 +130,36 @@ def test_plugin_launchers_are_parallel(self, target): launcher = OmegaConf.create({"_target_": target}) assert launcher_is_parallel(launcher) is True + def test_joblib_with_one_job_is_not_parallel(self): + """`n_jobs: 1` makes joblib run every job in the calling process. + + joblib's `Parallel(n_jobs=1)` uses the sequential backend, so this + launcher is as serial as BasicLauncher despite being a plugin. + """ + launcher = OmegaConf.create( + { + "_target_": "hydra_plugins.hydra_joblib_launcher" + ".joblib_launcher.JoblibLauncher", + "n_jobs": 1, + } + ) + assert launcher_is_parallel(launcher) is False + + def test_unreadable_n_jobs_falls_back_to_parallel(self): + """An `n_jobs` we cannot read must not crash the run. + + This is a predicate on the way to `batch_run`; raising here would abort + the whole experiment. Assuming parallel only costs a layer of nesting. + """ + launcher = OmegaConf.create( + { + "_target_": "hydra_plugins.hydra_joblib_launcher" + ".joblib_launcher.JoblibLauncher", + "n_jobs": "${undefined_key}", + } + ) + assert launcher_is_parallel(launcher) is True + @contextmanager def _inside_hydra_job(launcher_target: str, output_dir: Path): diff --git a/uv.lock b/uv.lock index fd5c1e0..3197164 100644 --- a/uv.lock +++ b/uv.lock @@ -8,7 +8,7 @@ resolution-markers = [ [[package]] name = "abses" -version = "0.10.0" +version = "0.11.7" source = { editable = "." } dependencies = [ { name = "fiona" },