Skip to content

Commit 4537cad

Browse files
committed
Execution on a cluster with Dask
1 parent 0aa7522 commit 4537cad

10 files changed

Lines changed: 273 additions & 38 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ ideas
1919
projects
2020
pkl
2121
tmp
22+
tmp*
2223
tox.ini
2324

2425
build

docs/source/CHANGELOG.md

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Changed
1111

12-
- Move from .rst to .md on the documentation.
12+
- Move from `.rst` to `.md` on the documentation.
1313
- Update installation instructions.
1414

15+
### Fixed
16+
17+
- The tests no longer rely on `/tmp`; they now create temporary files in the current directory instead. This approach was already used throughout the package, except in the tests. The change makes the behavior consistent and avoids issues that occurred on certain clusters when using `/tmp`.
18+
- Prevent race condition when creating `error` directory.
19+
20+
### Added
21+
22+
- Cluster configuration can be provided to calculate cost function on multiple nodes of a cluster.
23+
Cluster support requires installation of optional packages related to Dask, a package set called `cluster`.
24+
```
25+
pip install moldrug[cluster]
26+
```
27+
28+
An example of local parallel execution (same as before, for reference)
29+
30+
```python
31+
for _ in range(NumbCalls):
32+
ga(njobs=njobs)
33+
```
34+
35+
An example of parallel execution on a SLURM cluster
36+
37+
```python
38+
cluster = SLURMCluster(
39+
queue='short',
40+
cores=12,
41+
processes=1,
42+
memory='8GB',
43+
walltime='00:30:00',
44+
job_extra_directives=[]
45+
)
46+
47+
cluster.scale(4)
48+
49+
runner = Runner(RunnerMode.DASK_JOB_QUEUE_CLUSTER, dask_cluster=cluster)
50+
51+
for _ in range(NumbCalls):
52+
ga(runner=runner)
53+
```
54+
1555
## [3.7.3] - 2024.07.05
1656

1757
### Changed

docs/source/installation.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@ Strongly recommended; although this project is still in beta state.
1010
pip install moldrug
1111
```
1212

13+
````{note}
14+
To enable calculation of a cost function on [a cluster](https://jobqueue.dask.org/en/latest/clusters-api.html) install optional set of dependencies
15+
16+
```sh
17+
pip install moldrug[cluster]
18+
```
19+
````
20+
1321
````{admonition} Linux 🐧
1422
:class: Tip
1523
@@ -119,3 +127,5 @@ Finally, `pip install moldrug` inside the container.
119127
pip install -e .[dev]
120128
pytest tests
121129
```
130+
131+
To test execution on a cluster, change the `execution_mode` variable in `test_moldrug.py`. The tests you can run on the cluster afterward are called `test_single_receptor_command_line` and `test_multi_receptor`. Each cluster is different, and the current configuration is tailored to our two internal clusters. To test on your own cluster, you’ll also need to modify the `SLURMCluster` configuration in `test_moldrug.py`. During the execution of each test, multiple entries should appear in the Slurm queue.

docs/source/notebooks/quickstart.ipynb

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1133,6 +1133,34 @@
11331133
"source": [
11341134
"plot_dist(out.SawIndividuals, properties=['qed', 'sa_score', 'vina_score', 'cost'])"
11351135
]
1136+
},
1137+
{
1138+
"cell_type": "markdown",
1139+
"metadata": {},
1140+
"source": [
1141+
"By the default the calculations are running locally, but it is also possible to evaluate new generations on a cluster.\n",
1142+
"\n",
1143+
"First a cluster needs to be configured\n",
1144+
"\n",
1145+
"```python\n",
1146+
"cluster = SLURMCluster(\n",
1147+
" queue='short',\n",
1148+
" cores=12,\n",
1149+
" processes=1,\n",
1150+
" memory='8GB',\n",
1151+
" walltime='00:30:00',\n",
1152+
" job_extra_directives=[]\n",
1153+
")\n",
1154+
"\n",
1155+
"cluster.scale(4)\n",
1156+
"\n",
1157+
"runner = Runner(RunnerMode.DASK_JOB_QUEUE_CLUSTER, dask_cluster=cluster)\n",
1158+
"```\n",
1159+
"\n",
1160+
"and then each iteration takes the `runner` as an argument. That is one needs to call `out(runner=runner)` instead of `out(njobs=njobs)`.\n",
1161+
"\n",
1162+
"An example demonstrates use of SLURM, the other supported cluster types can be found here https://jobqueue.dask.org/en/latest/clusters-api.html."
1163+
]
11361164
}
11371165
],
11381166
"metadata": {

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ file = "LICENSE"
6363

6464
[project.optional-dependencies]
6565
dev = ["requests", "pytest"]
66+
cluster = ["dask[distributed]", "dask-jobqueue"]
6667

6768
[tool.versioningit]
6869
default-version = "1+unknown"

src/moldrug/constraintconf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ def generate_conformers(mol: Chem.rdchem.Mol,
162162
"""
163163
# Creating the error directory if needed
164164
if not os.path.isdir('error'):
165-
os.makedirs('error')
165+
os.makedirs('error', exist_ok=True)
166166
# if SMILES to be fixed are not given, assume to the MCS
167167
if ref_smi:
168168
if not Chem.MolFromSmiles(ref_smi):

src/moldrug/fitness.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,7 @@ def _vinadock(
311311

312312
# Creating the error directory if needed
313313
if not os.path.isdir('error'):
314-
os.makedirs('error')
314+
os.makedirs('error', exist_ok=True)
315315
# Creating the working directory if needed
316316
if not os.path.exists(wd):
317317
os.makedirs(wd)

src/moldrug/runner.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import multiprocessing as mp
2+
from enum import Enum
3+
from typing import Callable, List, Optional
4+
5+
import tqdm
6+
7+
try:
8+
from dask.distributed import Client
9+
from dask_jobqueue import JobQueueCluster
10+
11+
dask_available = True
12+
except ImportError:
13+
dask_available = False
14+
15+
16+
def serial_execution(func: Callable, entries: List):
17+
return [func(args) for args in tqdm.tqdm(entries, total=len(entries))]
18+
19+
20+
def parallel_execution_multiprocessing(func: Callable, entries: List, process_count: int):
21+
pool = mp.Pool(process_count)
22+
results = [entry for entry in tqdm.tqdm(pool.imap(func, entries), total=len(entries))]
23+
pool.close()
24+
25+
return results
26+
27+
28+
if dask_available:
29+
def parallel_execution_dask_local(func: Callable, entries: List, process_count: int, process_threads_count: int):
30+
client = Client(n_workers=process_count, threads_per_worker=process_threads_count)
31+
32+
return client.gather(client.map(func, entries))
33+
34+
35+
def parallel_execution_dask_cluster(func: Callable, entries: List, cluster: JobQueueCluster):
36+
client = Client(cluster)
37+
38+
return client.gather(client.map(func, entries))
39+
40+
41+
class RunnerMode(Enum):
42+
SERIAL = "serial"
43+
MULTIPROCESSING = "multiprocessing"
44+
DASK_LOCAL = "dask_local"
45+
DASK_JOB_QUEUE_CLUSTER = "dask_slurm"
46+
47+
48+
class Runner:
49+
mode: RunnerMode
50+
thread_count: Optional[int]
51+
process_count: Optional[int]
52+
dask_cluster = None
53+
54+
def __init__(self, mode: RunnerMode, thread_count: Optional[int] = None, process_count: Optional[int] = None, dask_cluster = None):
55+
if mode in [RunnerMode.DASK_LOCAL, RunnerMode.DASK_JOB_QUEUE_CLUSTER]:
56+
assert dask_cluster is not None, "Execution using Dask requires installation of optional dependencies. The optional pip package group is called 'dask'"
57+
58+
if mode is RunnerMode.SERIAL:
59+
assert thread_count is None and process_count is None and dask_cluster is None, "Serial execution doesn't take any parameters."
60+
elif mode in [RunnerMode.MULTIPROCESSING]:
61+
assert thread_count is None and process_count is not None and dask_cluster is None, "Only process count is needed."
62+
elif mode in [RunnerMode.DASK_LOCAL]:
63+
assert (thread_count is not None or process_count is None) and dask_cluster is None, "Only process count and/or thread count are needed."
64+
elif mode in [RunnerMode.DASK_JOB_QUEUE_CLUSTER]:
65+
assert thread_count is None and process_count is None and dask_cluster is not None, "Dask execution takes only a Dask cluster object."
66+
else:
67+
assert False
68+
69+
self.mode = mode
70+
self.thread_count = thread_count
71+
self.process_count = process_count
72+
self.cluster = dask_cluster
73+
74+
def run(self, func: Callable, entries: List):
75+
if self.mode == RunnerMode.SERIAL:
76+
return serial_execution(func, entries)
77+
elif self.mode == RunnerMode.MULTIPROCESSING:
78+
return parallel_execution_multiprocessing(func, entries, process_count=self.process_count)
79+
elif self.mode == RunnerMode.DASK_LOCAL:
80+
return parallel_execution_dask_local(func, entries, process_count=self.process_count, process_threads_count=self.thread_count)
81+
if self.mode == RunnerMode.DASK_JOB_QUEUE_CLUSTER:
82+
return parallel_execution_dask_cluster(func, entries, cluster=self.cluster)
83+
else:
84+
assert False

src/moldrug/utils.py

Lines changed: 14 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import bz2
44
import collections.abc
55
import datetime
6-
import multiprocessing as mp
76
import os
87
import random
98
import shutil
@@ -12,20 +11,20 @@
1211
import time
1312
from copy import deepcopy
1413
from inspect import signature
15-
from typing import Callable, Dict, Iterable, List, Union
14+
from typing import Callable, Dict, Iterable, List, Optional, Union
1615
from warnings import warn
1716

1817
import dill as pickle
1918
import numpy as np
2019
import pandas as pd
21-
import tqdm
2220
from crem.crem import grow_mol, mutate_mol
2321
from meeko import (MoleculePreparation, PDBQTMolecule, PDBQTWriterLegacy,
2422
RDKitMolCreate)
2523
from rdkit import Chem, RDLogger
2624
from rdkit.Chem import AllChem, DataStructs, Descriptors, Lipinski, rdFMCS
2725

2826
from moldrug import __version__
27+
from moldrug.runner import Runner, RunnerMode, parallel_execution_multiprocessing
2928

3029
RDLogger.DisableLog('rdApp.*')
3130
# # in order to pickle the isotope properties of the molecule
@@ -1164,9 +1163,7 @@ def __call__(self, njobs: int = 1, pick: int = None):
11641163
args_list.append((individual, kwargs_copy))
11651164

11661165
print('Calculating cost function...')
1167-
pool = mp.Pool(njobs)
1168-
self.pop = [individual for individual in tqdm.tqdm(pool.imap(self.__costfunc__, args_list), total=len(args_list))]
1169-
pool.close()
1166+
self.pop = parallel_execution_multiprocessing(self.__costfunc__, args_list, njobs)
11701167

11711168
# Clean directory
11721169
costfunc_jobs_tmp_dir.cleanup()
@@ -1408,7 +1405,7 @@ def __init__(self, seed_mol: Union[Chem.rdchem.Mol, Iterable[Chem.rdchem.Mol]],
14081405
self.InitIndividual = Individual(self._seed_mol[0], idx=0, randomseed=self.randomseed)
14091406
self.pop = []
14101407

1411-
def __call__(self, njobs: int = 1):
1408+
def __call__(self, njobs: int = 1, runner: Optional[Runner] = None):
14121409
"""Call definition
14131410
14141411
Parameters
@@ -1421,6 +1418,12 @@ def __call__(self, njobs: int = 1):
14211418
RuntimeError
14221419
Error during the initialization of the population.
14231420
"""
1421+
if njobs > 1:
1422+
assert runner is None, "Both njobs > 1 and runner have been specified. Please use only one of the parameters."
1423+
1424+
if runner is None:
1425+
runner = Runner(RunnerMode.MULTIPROCESSING, process_count=njobs)
1426+
14241427
ts = time.time()
14251428
# Counting the calls
14261429
self.NumCalls += 1
@@ -1492,18 +1495,8 @@ def __call__(self, njobs: int = 1):
14921495
args_list.append((individual, kwargs_copy))
14931496

14941497
print(f'\n\nCreating the first population with {len(self.pop)} members:')
1495-
try:
1496-
pool = mp.Pool(njobs)
1497-
self.pop = [individual for individual in tqdm.tqdm(pool.imap(self.__costfunc__, args_list), total=len(args_list))]
1498-
pool.close()
1499-
except Exception as e1:
1500-
warn("Parallelization did not work. Trying with serial...")
1501-
try:
1502-
self.pop = [self.__costfunc__(args) for args in tqdm.tqdm(args_list, total=len(args_list))]
1503-
except Exception as e2:
1504-
raise RuntimeError("Serial did not work either. Here are the ucurred exceptions:\n"
1505-
f"=========Parellel=========:\n {e1}\n"
1506-
f"==========Serial==========:\n {e2}")
1498+
self.pop = runner.run(self.__costfunc__, entries=args_list)
1499+
15071500
# Clean directory
15081501
costfunc_jobs_tmp_dir.cleanup()
15091502

@@ -1603,19 +1596,8 @@ def __call__(self, njobs: int = 1):
16031596
args_list.append((individual, kwargs_copy))
16041597
print(f'Evaluating generation {self.NumGens} / {self.maxiter + number_of_previous_generations}:')
16051598

1606-
# Calculating cost fucntion in parallel
1607-
try:
1608-
pool = mp.Pool(njobs)
1609-
popc = [individual for individual in tqdm.tqdm(pool.imap(self.__costfunc__, args_list), total=len(args_list))]
1610-
pool.close()
1611-
except Exception as e1:
1612-
warn("Parallelization did not work. Trying with serial...")
1613-
try:
1614-
popc = [self.__costfunc__(args) for args in tqdm.tqdm(args_list, total=len(args_list))]
1615-
except Exception as e2:
1616-
raise RuntimeError("Serial did not work either. Here are the ucurred exceptions:\n"
1617-
f"=========Parellel=========:\n {e1}\n"
1618-
f"==========Serial==========:\n {e2}")
1599+
# Calculating cost function in parallel
1600+
popc = runner.run(self.__costfunc__, args_list)
16191601

16201602
# Clean directory
16211603
costfunc_jobs_tmp_dir.cleanup()

0 commit comments

Comments
 (0)