Skip to content

Commit c82d1f9

Browse files
Make Storage objects picklable and resumable by mp.Pool workers (microsoft#974)
# Pull Request ## Title Make Storage objects picklable and resumable by mp.Pool workers. ______________________________________________________________________ ## Description To support a `ParallelTrialScheduler` with `Trials` running thru `TrialRunners` in separate processes using `multiprocessing.Pool` we need to be able to "send" all necessary state to "blank" python processes using pickle since python doesn't actually use a `fork` strategy for multiprocessing. This change enables that by doing the following: 1. Allow pickling the core `Storage` class by hooking `__getstate__` and `__setstate__` to disconnect the `Engine` prior to pickling and reconnect on unpickling (restore). 2. Allow recreating an `Experiment` and `Trial` object by fetching it from the `Storage` by `experiment_id` and `trial_id` respectively. These two things allow gathering the details necessary for a `TrialRunner` running in a separate child process to reconnect to the DB independently, `run_trial`, and save the telemetry and results to the DB. After which, the "MainProcess" can `load` the results from the Experiment from the Storage again to `bulk_register` the scores from those completed `Trials` and start a new set. ______________________________________________________________________ ## Type of Change - 🛠️ Bug fix - ✨ New feature - 🔄 Refactor - 🧪 Tests ______________________________________________________________________ ## Testing - [x] New CI tests ______________________________________________________________________ ## Additional Notes (optional) This change should have no impact on the existing `SyncScheduler`. ______________________________________________________________________ --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent ca4c78e commit c82d1f9

7 files changed

Lines changed: 297 additions & 19 deletions

File tree

mlos_bench/mlos_bench/environments/status.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
"""Enum for the status of the benchmark/environment Trial or Experiment."""
66

77
import enum
8+
import logging
9+
10+
_LOG = logging.getLogger(__name__)
811

912

1013
class Status(enum.Enum):
@@ -19,6 +22,21 @@ class Status(enum.Enum):
1922
FAILED = 6
2023
TIMED_OUT = 7
2124

25+
@staticmethod
26+
def from_str(status_str: str) -> "Status":
27+
"""Convert a string to a Status enum."""
28+
if status_str.isdigit():
29+
try:
30+
return Status(int(status_str))
31+
except ValueError:
32+
_LOG.warning("Unknown status: %d", int(status_str))
33+
try:
34+
status_str = status_str.upper()
35+
return Status[status_str]
36+
except KeyError:
37+
_LOG.warning("Unknown status: %s", status_str)
38+
return Status.UNKNOWN
39+
2240
def is_good(self) -> bool:
2341
"""Check if the status of the benchmark/environment is good."""
2442
return self in {
@@ -73,4 +91,4 @@ def is_timed_out(self) -> bool:
7391
"""Check if the status of the benchmark/environment Trial or Experiment is
7492
TIMED_OUT.
7593
"""
76-
return self == Status.FAILED
94+
return self == Status.TIMED_OUT

mlos_bench/mlos_bench/storage/base_storage.py

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
Base interface for accessing the stored benchmark trial data.
2323
"""
2424

25+
from __future__ import annotations
26+
2527
import logging
2628
from abc import ABCMeta, abstractmethod
2729
from collections.abc import Iterator, Mapping
@@ -94,6 +96,42 @@ def experiments(self) -> dict[str, ExperimentData]:
9496
A dictionary of the experiments' data, keyed by experiment id.
9597
"""
9698

99+
@abstractmethod
100+
def get_experiment_by_id(
101+
self,
102+
experiment_id: str,
103+
tunables: TunableGroups,
104+
opt_targets: dict[str, Literal["min", "max"]],
105+
) -> Storage.Experiment | None:
106+
"""
107+
Gets an Experiment by its ID.
108+
109+
Parameters
110+
----------
111+
experiment_id : str
112+
ID of the Experiment to retrieve.
113+
tunables : TunableGroups
114+
The tunables for the Experiment.
115+
opt_targets : dict[str, Literal["min", "max"]]
116+
The optimization targets for the Experiment's
117+
:py:class:`~mlos_bench.optimizers.base_optimizer.Optimizer`.
118+
119+
Returns
120+
-------
121+
experiment : Storage.Experiment | None
122+
The Experiment object, or None if it doesn't exist.
123+
124+
Notes
125+
-----
126+
Tunables are not stored in the database for the Experiment, only for the
127+
Trials, so currently they can change if the user (incorrectly) adjusts
128+
the configs on disk between resume runs.
129+
Since this method is generally meant to load th Experiment from the
130+
database for a child process to execute a Trial in the background we are
131+
generally safe to simply pass these values from the parent process
132+
rather than look them up in the database.
133+
"""
134+
97135
@abstractmethod
98136
def experiment( # pylint: disable=too-many-arguments
99137
self,
@@ -104,10 +142,12 @@ def experiment( # pylint: disable=too-many-arguments
104142
description: str,
105143
tunables: TunableGroups,
106144
opt_targets: dict[str, Literal["min", "max"]],
107-
) -> "Storage.Experiment":
145+
) -> Storage.Experiment:
108146
"""
109-
Create a new experiment in the storage.
147+
Create or reload an experiment in the Storage.
110148
149+
Notes
150+
-----
111151
We need the `opt_target` parameter here to know what metric to retrieve
112152
when we load the data from previous trials. Later we will replace it with
113153
full metadata about the optimization direction, multiple objectives, etc.
@@ -161,7 +201,7 @@ def __init__( # pylint: disable=too-many-arguments
161201
self._opt_targets = opt_targets
162202
self._in_context = False
163203

164-
def __enter__(self) -> "Storage.Experiment":
204+
def __enter__(self) -> Storage.Experiment:
165205
"""
166206
Enter the context of the experiment.
167207
@@ -307,14 +347,33 @@ def load(
307347
Trial ids, Tunable values, benchmark scores, and status of the trials.
308348
"""
309349

350+
@abstractmethod
351+
def get_trial_by_id(
352+
self,
353+
trial_id: int,
354+
) -> Storage.Trial | None:
355+
"""
356+
Gets a Trial by its ID.
357+
358+
Parameters
359+
----------
360+
trial_id : int
361+
ID of the Trial to retrieve for this Experiment.
362+
363+
Returns
364+
-------
365+
trial : Storage.Trial | None
366+
The Trial object, or None if it doesn't exist.
367+
"""
368+
310369
@abstractmethod
311370
def pending_trials(
312371
self,
313372
timestamp: datetime,
314373
*,
315374
running: bool,
316375
trial_runner_assigned: bool | None = None,
317-
) -> Iterator["Storage.Trial"]:
376+
) -> Iterator[Storage.Trial]:
318377
"""
319378
Return an iterator over :py:attr:`~.Status.PENDING`
320379
:py:class:`~.Storage.Trial` instances that have a scheduled start time to
@@ -345,7 +404,7 @@ def new_trial(
345404
tunables: TunableGroups,
346405
ts_start: datetime | None = None,
347406
config: dict[str, Any] | None = None,
348-
) -> "Storage.Trial":
407+
) -> Storage.Trial:
349408
"""
350409
Create a new experiment run in the storage.
351410
@@ -382,7 +441,7 @@ def _new_trial(
382441
tunables: TunableGroups,
383442
ts_start: datetime | None = None,
384443
config: dict[str, Any] | None = None,
385-
) -> "Storage.Trial":
444+
) -> Storage.Trial:
386445
"""
387446
Create a new experiment run in the storage.
388447
@@ -419,10 +478,11 @@ def __init__( # pylint: disable=too-many-arguments
419478
tunable_config_id: int,
420479
trial_runner_id: int | None,
421480
opt_targets: dict[str, Literal["min", "max"]],
481+
status: Status,
482+
restoring: bool,
422483
config: dict[str, Any] | None = None,
423-
status: Status = Status.UNKNOWN,
424484
):
425-
if status not in (Status.UNKNOWN, Status.PENDING):
485+
if not restoring and status not in (Status.UNKNOWN, Status.PENDING):
426486
raise ValueError(f"Invalid status for a new trial: {status}")
427487
self._tunables = tunables
428488
self._experiment_id = experiment_id
@@ -439,6 +499,11 @@ def __repr__(self) -> str:
439499
f"{self._tunable_config_id}:{self.trial_runner_id}"
440500
)
441501

502+
@property
503+
def experiment_id(self) -> str:
504+
"""Experiment ID of the Trial."""
505+
return self._experiment_id
506+
442507
@property
443508
def trial_id(self) -> int:
444509
"""ID of the current trial."""

mlos_bench/mlos_bench/storage/sql/common.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ def get_trials(
9595
config_id=trial.config_id,
9696
ts_start=utcify_timestamp(trial.ts_start, origin="utc"),
9797
ts_end=utcify_nullable_timestamp(trial.ts_end, origin="utc"),
98-
status=Status[trial.status],
98+
status=Status.from_str(trial.status),
9999
trial_runner_id=trial.trial_runner_id,
100100
)
101101
for trial in trials.fetchall()

mlos_bench/mlos_bench/storage/sql/experiment.py

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ def load(
188188
status: list[Status] = []
189189

190190
for trial in cur_trials.fetchall():
191-
stat = Status[trial.status]
191+
stat = Status.from_str(trial.status)
192192
status.append(stat)
193193
trial_ids.append(trial.trial_id)
194194
configs.append(
@@ -235,6 +235,48 @@ def _get_key_val(conn: Connection, table: Table, field: str, **kwargs: Any) -> d
235235
row._tuple() for row in cur_result.fetchall() # pylint: disable=protected-access
236236
)
237237

238+
def get_trial_by_id(
239+
self,
240+
trial_id: int,
241+
) -> Storage.Trial | None:
242+
with self._engine.connect() as conn:
243+
cur_trial = conn.execute(
244+
self._schema.trial.select().where(
245+
self._schema.trial.c.exp_id == self._experiment_id,
246+
self._schema.trial.c.trial_id == trial_id,
247+
)
248+
)
249+
trial = cur_trial.fetchone()
250+
if trial is None:
251+
return None
252+
tunables = self._get_key_val(
253+
conn,
254+
self._schema.config_param,
255+
"param",
256+
config_id=trial.config_id,
257+
)
258+
config = self._get_key_val(
259+
conn,
260+
self._schema.trial_param,
261+
"param",
262+
exp_id=self._experiment_id,
263+
trial_id=trial.trial_id,
264+
)
265+
return Trial(
266+
engine=self._engine,
267+
schema=self._schema,
268+
# Reset .is_updated flag after the assignment:
269+
tunables=self._tunables.copy().assign(tunables).reset(),
270+
experiment_id=self._experiment_id,
271+
trial_id=trial.trial_id,
272+
config_id=trial.config_id,
273+
trial_runner_id=trial.trial_runner_id,
274+
opt_targets=self._opt_targets,
275+
status=Status.from_str(trial.status),
276+
restoring=True,
277+
config=config,
278+
)
279+
238280
def pending_trials(
239281
self,
240282
timestamp: datetime,
@@ -288,6 +330,8 @@ def pending_trials(
288330
config_id=trial.config_id,
289331
trial_runner_id=trial.trial_runner_id,
290332
opt_targets=self._opt_targets,
333+
status=Status.from_str(trial.status),
334+
restoring=True,
291335
config=config,
292336
)
293337

@@ -363,8 +407,9 @@ def _new_trial(
363407
config_id=config_id,
364408
trial_runner_id=None, # initially, Trials are not assigned to a TrialRunner
365409
opt_targets=self._opt_targets,
366-
config=config,
367410
status=new_trial_status,
411+
restoring=False,
412+
config=config,
368413
)
369414
self._trial_id += 1
370415
return trial

mlos_bench/mlos_bench/storage/sql/storage.py

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import logging
88
from typing import Literal
99

10-
from sqlalchemy import URL, create_engine
10+
from sqlalchemy import URL, Engine, create_engine
1111

1212
from mlos_bench.services.base_service import Service
1313
from mlos_bench.storage.base_experiment_data import ExperimentData
@@ -25,28 +25,53 @@ class SqlStorage(Storage):
2525
backend.
2626
"""
2727

28+
# pylint: disable=too-many-instance-attributes
29+
2830
def __init__(
2931
self,
3032
config: dict,
3133
global_config: dict | None = None,
3234
service: Service | None = None,
3335
):
3436
super().__init__(config, global_config, service)
35-
lazy_schema_create = self._config.pop("lazy_schema_create", False)
37+
self._lazy_schema_create = self._config.pop("lazy_schema_create", False)
3638
self._log_sql = self._config.pop("log_sql", False)
3739
self._url = URL.create(**self._config)
3840
self._repr = f"{self._url.get_backend_name()}:{self._url.database}"
41+
self._engine: Engine
42+
self._db_schema: DbSchema
43+
self._schema_created = False
44+
self._schema_updated = False
45+
self._init_engine()
46+
47+
def _init_engine(self) -> None:
48+
"""Initialize the SQLAlchemy engine."""
49+
# This is a no-op, as the engine is created in __init__.
3950
_LOG.info("Connect to the database: %s", self)
4051
self._engine = create_engine(self._url, echo=self._log_sql)
4152
self._db_schema = DbSchema(self._engine)
42-
self._schema_created = False
43-
self._schema_updated = False
44-
if not lazy_schema_create:
53+
if not self._lazy_schema_create:
4554
assert self._schema
4655
self.update_schema()
4756
else:
4857
_LOG.info("Using lazy schema create for database: %s", self)
4958

59+
# Make the object picklable.
60+
61+
def __getstate__(self) -> dict:
62+
"""Return the state of the object for pickling."""
63+
state = self.__dict__.copy()
64+
# Don't pickle the engine, as it cannot be pickled.
65+
state.pop("_engine", None)
66+
state.pop("_db_schema", None)
67+
return state
68+
69+
def __setstate__(self, state: dict) -> None:
70+
"""Restore the state of the object from pickling."""
71+
self.__dict__.update(state)
72+
# Recreate the engine and schema.
73+
self._init_engine()
74+
5075
@property
5176
def _schema(self) -> DbSchema:
5277
"""Lazily create schema upon first access."""
@@ -66,6 +91,32 @@ def update_schema(self) -> None:
6691
def __repr__(self) -> str:
6792
return self._repr
6893

94+
def get_experiment_by_id(
95+
self,
96+
experiment_id: str,
97+
tunables: TunableGroups,
98+
opt_targets: dict[str, Literal["min", "max"]],
99+
) -> Storage.Experiment | None:
100+
with self._engine.connect() as conn:
101+
cur_exp = conn.execute(
102+
self._schema.experiment.select().where(
103+
self._schema.experiment.c.exp_id == experiment_id,
104+
)
105+
)
106+
exp = cur_exp.fetchone()
107+
if exp is None:
108+
return None
109+
return Experiment(
110+
engine=self._engine,
111+
schema=self._schema,
112+
experiment_id=exp.exp_id,
113+
trial_id=-1, # will be loaded upon __enter__ which calls _setup()
114+
description=exp.description,
115+
root_env_config=exp.root_env_config,
116+
tunables=tunables,
117+
opt_targets=opt_targets,
118+
)
119+
69120
def experiment( # pylint: disable=too-many-arguments
70121
self,
71122
*,

0 commit comments

Comments
 (0)