Skip to content

Commit d939168

Browse files
authored
Merge pull request #13 from w8jcik/add/dask
This looks great! Distributed parallelization will enable moldrug to scale more effectively on HPC systems, making it practical to use more computationally demanding fitness functions. Thanks again, @w8jcik!
2 parents f836731 + e0feb61 commit d939168

11 files changed

Lines changed: 311 additions & 42 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: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,50 @@ 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 during parallel execution.
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+
- ```sh
26+
pip install moldrug[cluster]
27+
```
28+
29+
An example of local parallel execution (same as before, for reference)
30+
31+
```python
32+
for _ in range(NumbCalls):
33+
ga(njobs=njobs)
34+
```
35+
36+
An example of parallel execution on a SLURM cluster
37+
38+
```python
39+
cluster = SLURMCluster(
40+
queue='short',
41+
cores=12,
42+
processes=1,
43+
memory='8GB',
44+
walltime='00:30:00',
45+
job_extra_directives=[]
46+
)
47+
48+
cluster.scale(4)
49+
50+
runner = Runner(RunnerMode.DASK_JOB_QUEUE_CLUSTER, dask_cluster=cluster)
51+
52+
for _ in range(NumbCalls):
53+
ga(runner=runner)
54+
```
55+
1556
## [3.7.3] - 2024.07.05
1657

1758
### 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/cli.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import argparse
99
import datetime
1010
import inspect
11+
import importlib
1112
import os
1213
import sys
1314
from typing import Union
@@ -114,7 +115,8 @@ def _translate_config(self):
114115
InitArgs = MainConfig.copy()
115116

116117
# Modifying InitArgs
117-
_ = [InitArgs.pop(key, None) for key in ['type', 'njobs', 'pick']]
118+
for key in ['type', 'njobs', 'cluster', 'pick']:
119+
InitArgs.pop(key, None)
118120
InitArgs['costfunc'] = self.costfunc
119121

120122
# Getting call arguments
@@ -125,6 +127,23 @@ def _translate_config(self):
125127
except KeyError:
126128
pass
127129

130+
if 'cluster' in MainConfig:
131+
if 'type' not in MainConfig['cluster'] or 'kwargs' not in MainConfig['cluster']:
132+
raise ValueError("The cluster configuration must contain 'type' and 'kwargs'.")
133+
134+
from moldrug.runner import Runner, RunnerMode # terminates with a message if dask is not installed
135+
136+
try:
137+
cluster_class = getattr(importlib.import_module("dask_jobqueue"), MainConfig['cluster']['type'])
138+
except ImportError:
139+
raise ImportError(f"Unable to import {MainConfig['cluster']['type']} from dask_jobqueue module.")
140+
141+
cluster = cluster_class(**MainConfig['cluster']['kwargs'])
142+
cluster.scale(MainConfig.get('njobs', 1))
143+
144+
CallArgs['runner'] = Runner(RunnerMode.DASK_JOB_QUEUE_CLUSTER, dask_cluster=cluster)
145+
del CallArgs['njobs']
146+
128147
# Checking for follow jobs and sanity check on the arguments
129148
if FollowConfig:
130149
# Defining the possible mutable arguments with its default values depending on the type of run
@@ -145,7 +164,7 @@ def _translate_config(self):
145164
InitArgs[param.name] = param.default
146165

147166
MutableArgs = {
148-
'njobs': CallArgs['njobs'],
167+
'njobs': MainConfig['njobs'],
149168
'crem_db_path': InitArgs['crem_db_path'],
150169
'maxiter': InitArgs['maxiter'],
151170
'popsize': InitArgs['popsize'],

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

0 commit comments

Comments
 (0)