Skip to content

Commit d62dd6c

Browse files
committed
[WIP] Rescheduling runs before timeout
1 parent 1539c45 commit d62dd6c

6 files changed

Lines changed: 254 additions & 17 deletions

File tree

ci/examples/example_reschedule.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import logging
2+
from time import sleep
3+
4+
from seml import Experiment
5+
6+
ex = Experiment()
7+
8+
9+
@ex.reschedule_hook
10+
def reschedule(step: int):
11+
logging.info(f'Reschedule triggered at step {step}.')
12+
return {'checkpoint': step}
13+
14+
15+
@ex.automain
16+
def run(
17+
n_steps: int,
18+
checkpoint: int | None = None,
19+
):
20+
logging.info('Starting experiment with the following parameters:')
21+
logging.info(f'n_steps: {n_steps}, checkpoint: {checkpoint}')
22+
23+
if checkpoint is not None:
24+
logging.info(f'Resuming from checkpoint: {checkpoint}')
25+
# Load your model/state from the checkpoint here
26+
27+
# Simulate some processing
28+
for step in range(checkpoint or 0, n_steps):
29+
reschedule(step)
30+
logging.info(f'Processing step {step + 1}/{n_steps}')
31+
sleep(1.0)
32+
33+
logging.info('Experiment completed successfully.')
34+
return
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
seml:
2+
executable: example_reschedule.py
3+
name: example_reschedule_experiment
4+
output_dir: logs
5+
project_root_dir: .
6+
description: An example configuration with rescheduling.
7+
8+
slurm:
9+
- experiments_per_job: 1
10+
sbatch_options:
11+
mem: 1G
12+
cpus-per-task: 1
13+
time: 00:01:00
14+
partition: cpu_all
15+
16+
fixed:
17+
n_steps: 1000

src/seml/commands/start.py

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,8 @@ def start_sbatch_job(
138138
sbatch_options: SBatchOptions,
139139
unobserved: bool = False,
140140
name: str | None = None,
141-
output_dir_path: str = '.',
141+
output_path: str = '.',
142+
output_to_file: bool = True,
142143
max_simultaneous_jobs: int | None = None,
143144
experiments_per_job: int = 1,
144145
debug_server: bool = False,
@@ -183,13 +184,17 @@ def start_sbatch_job(
183184
raise ConfigError(
184185
"Can't set sbatch `output` Parameter explicitly. SEML will do that for you."
185186
)
186-
elif output_dir_path == '/dev/null':
187-
output_file = output_dir_path
187+
elif not output_to_file:
188+
slurm_output_file = '/dev/null'
188189
else:
189-
output_file = f'{output_dir_path}/{name}_%A_%a.out'
190+
slurm_output_file = f'{output_path}/{name}_%A_%a.out'
190191
# Ensure that the output path exists
191-
Path(output_file).parent.mkdir(exist_ok=True)
192-
sbatch_options['output'] = output_file
192+
Path(slurm_output_file).parent.mkdir(exist_ok=True)
193+
194+
reschedule_output_file = f'{output_path}/{name}_%A_%a.reschedule'
195+
Path(reschedule_output_file).parent.mkdir(exist_ok=True)
196+
197+
sbatch_options['output'] = slurm_output_file
193198
sbatch_options['job-name'] = name
194199

195200
# Construct srun options if experiments_per_job > 1
@@ -229,13 +234,17 @@ def start_sbatch_job(
229234
'tmp_directory': SETTINGS.TMP_DIRECTORY,
230235
'experiments_per_job': str(experiments_per_job),
231236
'maybe_srun': srun_str,
237+
'reschedule_file': reschedule_output_file,
232238
}
233239
variables = {
234240
**variables,
235241
'setup_command': SETTINGS.SETUP_COMMAND.format(**variables),
236242
'end_command': SETTINGS.END_COMMAND.format(**variables),
237243
}
238244
# Construct Slurm script
245+
# TODO: TIGRU: Make the time limit for rescheduling configurable
246+
# TODO: TIGRU: Potentially remove requeuing from template and create a new experiment
247+
# from the reschedule_hook
239248
template = load_text_resource('templates/slurm/slurm_template.sh')
240249
script = template.format(**variables)
241250

@@ -261,7 +270,10 @@ def start_sbatch_job(
261270
return
262271

263272
slurm_array_job_id = int(output.split(b' ')[-1])
264-
output_file = output_file.replace('%A', str(slurm_array_job_id))
273+
slurm_output_file = slurm_output_file.replace('%A', str(slurm_array_job_id))
274+
reschedule_output_file = reschedule_output_file.replace(
275+
'%A', str(slurm_array_job_id)
276+
)
265277
cluster_name = get_cluster_name()
266278
collection.update_many(
267279
{'_id': {'$in': [exp['_id'] for exp in exp_array]}},
@@ -270,7 +282,8 @@ def start_sbatch_job(
270282
'status': States.PENDING[0],
271283
f'slurm.{slurm_options_id}.array_id': slurm_array_job_id,
272284
f'slurm.{slurm_options_id}.num_tasks': num_tasks,
273-
f'slurm.{slurm_options_id}.output_files_template': output_file,
285+
f'slurm.{slurm_options_id}.output_files_template': slurm_output_file,
286+
f'slurm.{slurm_options_id}.reschedule_file': reschedule_output_file,
274287
f'slurm.{slurm_options_id}.sbatch_options': sbatch_options,
275288
'execution.cluster': cluster_name,
276289
}
@@ -616,10 +629,7 @@ def add_to_slurm_queue(
616629
)
617630
narrays += 1
618631
else:
619-
if output_to_file:
620-
output_dir_path = get_and_make_output_dir_path(exp_array[0])
621-
else:
622-
output_dir_path = '/dev/null'
632+
output_dir_path = get_and_make_output_dir_path(exp_array[0])
623633
assert not post_mortem
624634
for slurm_options_id, slurm_option in enumerate(slurm_options):
625635
set_slurm_job_name(
@@ -635,7 +645,8 @@ def add_to_slurm_queue(
635645
slurm_option['sbatch_options'],
636646
unobserved,
637647
name=job_name,
638-
output_dir_path=output_dir_path,
648+
output_path=output_dir_path,
649+
output_to_file=output_to_file,
639650
max_simultaneous_jobs=slurm_option.get('max_simultaneous_jobs'),
640651
experiments_per_job=slurm_option.get('experiments_per_job', 1),
641652
debug_server=debug_server,
@@ -1128,9 +1139,16 @@ def claim_experiment(db_collection_name: str, exp_ids: Sequence[int]):
11281139
if s_conf['array_id'] == array_id:
11291140
output_file = s_conf['output_files_template']
11301141
output_file = output_file.replace('%a', str(task_id))
1142+
reschedule_file = s_conf.get('reschedule_file', '')
1143+
reschedule_file = reschedule_file.replace('%a', str(task_id))
11311144
collection.update_one(
11321145
{'_id': exp['_id']},
1133-
{'$set': {'execution.slurm_output_file': output_file}},
1146+
{
1147+
'$set': {
1148+
'execution.slurm_output_file': output_file,
1149+
'execution.reschedule_file': reschedule_file,
1150+
}
1151+
},
11341152
)
11351153
else:
11361154
# Steal slurm

src/seml/document.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,11 +156,15 @@ class SlurmDoc(SlurmConfig):
156156
The number of tasks in the SLURM job.
157157
output_files_template : str
158158
The template for the output files. The template must contain the placeholders {array_id} and {task_id}.
159+
reschedule_file : str
160+
The path to the reschedule file for this SLURM job. This is set regardless of whether an experiment
161+
is actually executed by this SLURM job, i.e. whether the job manages to claim the experiment.
159162
"""
160163

161164
array_id: int
162165
num_tasks: int
163166
output_files_template: str
167+
reschedule_file: str
164168

165169

166170
class GitDoc(TypedDict):
@@ -196,12 +200,16 @@ class ExecutionDoc(TypedDict):
196200
The task ID of the SLURM job.
197201
slurm_output_file: str
198202
The output file of the SLURM job.
203+
reschedule_file: str
204+
The reschedule file of the SLURM job. This is only set once an experiment is actually
205+
executed by the SLURM job.
199206
"""
200207

201208
cluster: str
202209
array_id: int
203210
task_id: int
204211
slurm_output_file: str
212+
reschedule_file: str
205213

206214

207215
class SacredExperimentDoc(TypedDict):

src/seml/experiment/experiment.py

Lines changed: 147 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@
33
import resource
44
import sys
55
from enum import Enum
6-
from typing import TYPE_CHECKING, List, Optional, Sequence, Union, cast
6+
from pathlib import Path
7+
from typing import TYPE_CHECKING, List, Literal, Optional, Sequence, Union, cast
78

8-
from seml.database import get_collection
9+
from pymongo.collection import Collection
10+
11+
from seml.database import States, get_collection
12+
from seml.document import ExperimentDoc
913
from seml.experiment.observers import create_mongodb_observer
1014
from seml.settings import SETTINGS
1115
from seml.utils.multi_process import is_main_process
@@ -24,6 +28,7 @@
2428
recursive_fill_in,
2529
undogmatize,
2630
)
31+
from sacred.utils import SacredInterrupt
2732

2833

2934
class LoggerOptions(Enum):
@@ -32,7 +37,13 @@ class LoggerOptions(Enum):
3237
RICH = 'rich'
3338

3439

40+
class RescheduleInterrupt(SacredInterrupt):
41+
STATUS = States.PENDING[0]
42+
43+
3544
class Experiment(ExperimentBase):
45+
_reschedule_watch_path: Path | None | Literal['INVALID']
46+
3647
def __init__(
3748
self,
3849
name: Optional[str] = None,
@@ -65,6 +76,8 @@ def __init__(
6576
if collect_stats:
6677
self.post_run_hook(lambda _run: _collect_exp_stats(_run))
6778

79+
self._reschedule_watch_path = None
80+
6881
def run(
6982
self,
7083
command_name: Optional[str] = None,
@@ -87,6 +100,138 @@ def run(
87100
options=options,
88101
)
89102

103+
def reschedule_hook(self, f):
104+
"""Decorator to register the reschedule hook.
105+
106+
In case of a required rescheduling, the decorated function will be called.
107+
It should return a config dictionary that will be used to update the current
108+
configuration before submitting the rescheduled job.
109+
110+
Parameters
111+
----------
112+
f: Callable[[..., dict]]
113+
User-defined function to be called when rescheduling is triggered.
114+
115+
Returns
116+
-------
117+
Callable
118+
Wrapped function that checks for rescheduling and calls the user-defined function.
119+
120+
121+
Example
122+
-------
123+
124+
```python
125+
from seml import Experiment
126+
ex = Experiment()
127+
128+
@ex.reschedule_hook
129+
def reschedule(step: int):
130+
print(f'Reschedule triggered at step {step}.')
131+
return {'checkpoint': step}
132+
133+
@ex.automain
134+
def run(n_steps: int, checkpoint: int | None = None):
135+
if checkpoint is not None:
136+
print(f'Resuming from checkpoint: {checkpoint}')
137+
for step in range(checkpoint or 0, n_steps):
138+
reschedule(step)
139+
print(f'Processing step {step + 1}/{n_steps}')
140+
print('Experiment completed successfully.')
141+
```
142+
"""
143+
144+
def _reschedule_hook(*args, **kwargs):
145+
# Check whether we must reschedule
146+
if self._reschedule_watch_path is None:
147+
self._reschedule_watch_path = (
148+
self._get_reschedule_watch_path() or 'INVALID'
149+
)
150+
if self._reschedule_watch_path == 'INVALID' or not is_main_process():
151+
return
152+
153+
_must_reschedule = Path(self._reschedule_watch_path).exists()
154+
if not _must_reschedule:
155+
return
156+
157+
# If yes, call the user-defined function
158+
logging.info('Caught reschedule signal, calling reschedule_hook.')
159+
new_config = f(*args, **kwargs)
160+
assert isinstance(new_config, dict), (
161+
'Reschedule hook must return a configuration dictionary.'
162+
f' Got type: + {type(new_config)}'
163+
)
164+
logging.info('Reschedule hook returned new configuration: ' f'{new_config}')
165+
166+
# Merge config update with experiment doc
167+
exp = self._get_exp_document_from_db()
168+
assert exp is not None, (
169+
'Could not retrieve experiment document from database.'
170+
' Rescheduling not possible.'
171+
)
172+
collection = self._get_db_collection()
173+
assert collection is not None
174+
# TODO: TIGRU: Update config of experiment document in the database
175+
176+
raise RescheduleInterrupt
177+
178+
return _reschedule_hook
179+
180+
def _get_db_collection(self) -> Optional[Collection[ExperimentDoc]]:
181+
assert self.current_run is not None
182+
db_collection = self.current_run.config.get('db_collection')
183+
if db_collection is None:
184+
logging.warning(
185+
'Reschedule hook called outside of a SEML-managed experiment.'
186+
' Rescheduling hook is ignored.'
187+
)
188+
return None
189+
db_collection = get_collection(db_collection)
190+
return db_collection
191+
192+
def _get_exp_id(self) -> Optional[int]:
193+
assert self.current_run is not None
194+
exp_id = self.current_run.config.get('overwrite')
195+
if exp_id is None:
196+
logging.warning(
197+
'Reschedule hook called without a SEML experiment ID.'
198+
' Is this execution unobserved? Rescheduling hook is ignored.'
199+
)
200+
return exp_id
201+
202+
def _get_exp_document_from_db(self) -> Optional[ExperimentDoc]:
203+
db_collection = self._get_db_collection()
204+
exp_id = self._get_exp_id()
205+
if db_collection is None or exp_id is None:
206+
return None
207+
exp = db_collection.find_one(exp_id)
208+
if exp is None:
209+
logging.warning(
210+
f'No experiment with ID {exp_id} found in database.'
211+
' Cannot retrieve experiment document.'
212+
)
213+
return exp
214+
215+
def _get_reschedule_watch_path(self) -> Optional[Path]:
216+
exp = self._get_exp_document_from_db()
217+
if exp is None:
218+
return
219+
220+
if exp.get('execution', {}).get('cluster', None) == 'local':
221+
logging.info('Experiment is executed locally. Reschedule hook is ignored.')
222+
return
223+
224+
reschedule_path = exp.get('execution', {}).get('reschedule_file', None)
225+
assert (
226+
reschedule_path is not None
227+
), 'No SLURM reschedule file recorded for this experiment in the database.'
228+
reschedule_path = Path(reschedule_path)
229+
logging.info(
230+
'Found signal path for reschedule hook.'
231+
f' Watching for file: {reschedule_path.as_posix()}'
232+
)
233+
return reschedule_path
234+
90235

91236
class MongoDbObserverConfig:
92237
def __init__(self, experiment: Experiment):

0 commit comments

Comments
 (0)