lightray 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
lightray/__init__.py ADDED
@@ -0,0 +1 @@
1
+ from .tune import run
lightray/callbacks.py ADDED
@@ -0,0 +1,63 @@
1
+ import os
2
+ import shutil
3
+ import time
4
+ from pathlib import Path
5
+
6
+ from botocore.exceptions import ClientError, ConnectTimeoutError
7
+ from ray import train
8
+ from ray.train import Checkpoint
9
+ from ray.train.lightning import RayTrainReportCallback
10
+
11
+ BOTO_RETRY_EXCEPTIONS = (ClientError, ConnectTimeoutError)
12
+
13
+
14
+ def report_with_retries(metrics, checkpoint, retries: int = 10):
15
+ """
16
+ Call `train.report`, which will persist checkpoints to s3,
17
+ retrying after any possible errors
18
+ """
19
+ for _ in range(retries):
20
+ try:
21
+ train.report(metrics=metrics, checkpoint=checkpoint)
22
+ break
23
+ except BOTO_RETRY_EXCEPTIONS:
24
+ time.sleep(5)
25
+ continue
26
+
27
+
28
+ class TrainReportCallback(RayTrainReportCallback):
29
+ """
30
+ Equivalent of the RayTrainReportCallback
31
+ (https://docs.ray.io/en/latest/train/api/doc/ray.train.lightning.RayTrainReportCallback.html)
32
+ except adds a retry mechanism to the `train.report` call
33
+ """
34
+
35
+ def on_train_epoch_end(self, trainer, pl_module) -> None:
36
+ # Creates a checkpoint dir with fixed name
37
+ tmpdir = Path(
38
+ self.tmpdir_prefix, str(trainer.current_epoch)
39
+ ).as_posix()
40
+ os.makedirs(tmpdir, exist_ok=True)
41
+
42
+ # Fetch metrics
43
+ metrics = trainer.callback_metrics
44
+ metrics = {k: v.item() for k, v in metrics.items()}
45
+
46
+ # (Optional) Add customized metrics
47
+ metrics["epoch"] = trainer.current_epoch
48
+ metrics["step"] = trainer.global_step
49
+
50
+ # Save checkpoint to local
51
+ ckpt_path = Path(tmpdir, self.CHECKPOINT_NAME).as_posix()
52
+ trainer.save_checkpoint(ckpt_path, weights_only=False)
53
+
54
+ # Report to train session
55
+ checkpoint = Checkpoint.from_directory(tmpdir)
56
+ report_with_retries(metrics=metrics, checkpoint=checkpoint)
57
+
58
+ # Add a barrier to ensure all workers finished reporting here
59
+ trainer.strategy.barrier()
60
+
61
+ if self.local_rank == 0:
62
+ shutil.rmtree(tmpdir)
63
+ shutil.rmtree(tmpdir)
lightray/fs.py ADDED
@@ -0,0 +1,43 @@
1
+ import os
2
+ from typing import Optional
3
+
4
+ import pyarrow
5
+ import s3fs
6
+
7
+ # s3 retry configuration
8
+ retry_config = {"retries": {"total_max_attempts": 10, "mode": "adaptive"}}
9
+
10
+
11
+ def setup_filesystem(
12
+ storage_dir: Optional[str] = None,
13
+ ) -> tuple[
14
+ Optional[pyarrow.fs.PyFileSystem], Optional[pyarrow.fs.PyFileSystem]
15
+ ]:
16
+ internal_fs = external_fs = None
17
+ if storage_dir and storage_dir.startswith("s3://"):
18
+ storage_dir = storage_dir.removeprefix("s3://")
19
+ endpoint_url = os.getenv("AWS_ENDPOINT_URL")
20
+ # directly use s3 instead of rays pyarrow s3 default due to
21
+ # this issue https://github.com/ray-project/ray/issues/41137
22
+ internal_fs = s3fs.S3FileSystem(
23
+ key=os.getenv("AWS_ACCESS_KEY_ID"),
24
+ secret=os.getenv("AWS_SECRET_ACCESS_KEY"),
25
+ endpoint_url=endpoint_url,
26
+ config_kwargs=retry_config,
27
+ )
28
+ internal_fs = pyarrow.fs.PyFileSystem(
29
+ pyarrow.fs.FSSpecHandler(internal_fs)
30
+ )
31
+
32
+ external_fs = s3fs.S3FileSystem(
33
+ key=os.getenv("AWS_ACCESS_KEY_ID"),
34
+ secret=os.getenv("AWS_SECRET_ACCESS_KEY"),
35
+ endpoint_url=os.getenv(
36
+ "AWS_EXTERNAL_ENDPOINT_URL", os.getenv("AWS_ENDPOINT_URL")
37
+ ),
38
+ config_kwargs=retry_config,
39
+ )
40
+ external_fs = pyarrow.fs.PyFileSystem(
41
+ pyarrow.fs.FSSpecHandler(external_fs)
42
+ )
43
+ return internal_fs, external_fs
lightray/tune.py ADDED
@@ -0,0 +1,110 @@
1
+ import logging
2
+ import os
3
+ from pathlib import Path
4
+ from typing import TYPE_CHECKING, Literal, Optional, Type, Union
5
+
6
+ import lightning.pytorch as pl
7
+ import ray
8
+ from ray import tune
9
+ from ray.tune.schedulers import TrialScheduler
10
+
11
+ from lightray import utils
12
+ from lightray.fs import setup_filesystem
13
+
14
+ if TYPE_CHECKING:
15
+ from lightning.pytorch.cli import LightningCLI
16
+
17
+
18
+ def run(
19
+ cli_cls: Type["LightningCLI"],
20
+ name: str,
21
+ metric_name: str,
22
+ objective: Literal["min", "max"],
23
+ search_space: Union[dict, Path],
24
+ scheduler: TrialScheduler,
25
+ storage_dir: Optional[str] = None,
26
+ address: Optional[str] = None,
27
+ num_samples: int = 10,
28
+ workers_per_trial: int = 1,
29
+ gpus_per_worker: float = 1.0,
30
+ cpus_per_gpu: float = 1.0,
31
+ callbacks: Optional[list[pl.callbacks.Callback]] = None,
32
+ temp_dir: Optional[str] = None,
33
+ args: Optional[list[str]] = None,
34
+ ) -> tune.ResultGrid:
35
+ # parse the training configuration file, and
36
+ # any argument overrides
37
+ # using the user passed LightningCLI class;
38
+ config = utils.parse_args(cli_cls, args)
39
+
40
+ # if specified, connect to a running ray cluster
41
+ # otherwise, ray will assume one is running locally
42
+ logging.info("Initializing Ray")
43
+ if address is not None:
44
+ logging.info(f"Connecting to Ray cluster at {address}")
45
+ # ensure the ray adress starts with "ray://"
46
+ if not address.startswith("ray://"):
47
+ raise ValueError(
48
+ f"Address must start with 'ray://', got {address}"
49
+ )
50
+ ray.init(address, _temp_dir=temp_dir)
51
+
52
+ logging.info("Initializing checkpoint storage filesystems")
53
+ internal_fs, external_fs = setup_filesystem(str(storage_dir))
54
+
55
+ # construct the function that will actually
56
+ # execute the training loop, and then set it
57
+ # up for Ray to distribute it over our cluster,
58
+ # with the desired number of resources allocated
59
+ # to each running version of the job
60
+ train_func = utils.configure_deployment(
61
+ utils.TrainFunc(cli_cls, name, config, callbacks),
62
+ metric_name=metric_name,
63
+ workers_per_trial=workers_per_trial,
64
+ gpus_per_worker=gpus_per_worker,
65
+ cpus_per_gpu=cpus_per_gpu,
66
+ objective=objective,
67
+ storage_dir=storage_dir or None,
68
+ fs=internal_fs,
69
+ )
70
+
71
+ search_space = utils.get_search_space(search_space)
72
+
73
+ # restore from a previous tuning run
74
+ path = os.path.join(storage_dir, name)
75
+ if tune.Tuner.can_restore(path, storage_filesystem=external_fs):
76
+ logging.info(f"Restoring from previous tuning run at {path}")
77
+ tuner = tune.Tuner.restore(
78
+ path,
79
+ train_func,
80
+ resume_errored=True,
81
+ storage_filesystem=external_fs,
82
+ )
83
+
84
+ else:
85
+ tuner = tune.Tuner(
86
+ train_func,
87
+ param_space={"train_loop_config": search_space},
88
+ tune_config=tune.TuneConfig(
89
+ metric=metric_name,
90
+ mode=objective,
91
+ num_samples=num_samples,
92
+ scheduler=scheduler,
93
+ reuse_actors=True,
94
+ trial_name_creator=lambda trial: f"{trial.trial_id}",
95
+ trial_dirname_creator=lambda trial: f"{trial.trial_id}",
96
+ ),
97
+ )
98
+
99
+ logging.info("Starting tune job")
100
+ results = tuner.fit()
101
+
102
+ return results
103
+
104
+
105
+ def cli():
106
+ pass
107
+
108
+
109
+ if __name__ == "__main__":
110
+ cli()
lightray/utils.py ADDED
@@ -0,0 +1,277 @@
1
+ """
2
+ Hyperparameter tuning utilities based largely on this tutorial
3
+ https://docs.ray.io/en/latest/tune/examples/tune-pytorch-lightning.html
4
+ """
5
+
6
+
7
+ import importlib
8
+ import math
9
+ import os
10
+ from tempfile import NamedTemporaryFile
11
+ from typing import Any, List, Optional, Type, Union
12
+
13
+ import lightning.pytorch as pl
14
+ import pyarrow.fs
15
+ import yaml
16
+ from lightning.pytorch.cli import LightningCLI
17
+ from ray import train
18
+ from ray.train import CheckpointConfig, FailureConfig, RunConfig, ScalingConfig
19
+ from ray.train.lightning import (
20
+ RayDDPStrategy,
21
+ RayLightningEnvironment,
22
+ prepare_trainer,
23
+ )
24
+ from ray.train.torch import TorchTrainer
25
+
26
+ from lightray.callbacks import RayTrainReportCallback
27
+
28
+
29
+ def get_host_cli(cli: Type[LightningCLI]):
30
+ """
31
+ Return a LightningCLI class that will utilize
32
+ the `cli` class passed as a parent class
33
+ for parsing arguments.
34
+
35
+ Since this is run on the client,
36
+ we don't actually want to do anything with the arguments we parse,
37
+ just record them, so override the couple parent
38
+ methods responsible for instantiating classes and running
39
+ subcommands.
40
+ """
41
+
42
+ class HostCLI(cli):
43
+ def instantiate_classes(self):
44
+ return
45
+
46
+ def _run_subcommand(self):
47
+ return
48
+
49
+ return HostCLI
50
+
51
+
52
+ def get_worker_cli(
53
+ cli: Type[LightningCLI],
54
+ callbacks: Optional[List[pl.callbacks.Callback]] = None,
55
+ ):
56
+ """
57
+ Return a LightningCLI class that will actually execute
58
+ training runs on worker nodes
59
+ """
60
+
61
+ callbacks = callbacks or [RayTrainReportCallback()]
62
+
63
+ class WorkerCLI(cli):
64
+ def instantiate_trainer(self, **kwargs):
65
+ kwargs = kwargs | dict(
66
+ enable_progress_bar=False,
67
+ devices="auto",
68
+ accelerator="auto",
69
+ strategy=RayDDPStrategy(),
70
+ callbacks=callbacks,
71
+ plugins=[RayLightningEnvironment()],
72
+ )
73
+ return super().instantiate_trainer(**kwargs)
74
+
75
+ return WorkerCLI
76
+
77
+
78
+ def get_search_space(search_space: Union[str, dict]) -> dict[str, callable]:
79
+ if isinstance(search_space, dict):
80
+ return search_space
81
+
82
+ # determine if the path is a file path or a module path
83
+ if os.path.isfile(search_space):
84
+ # load the module from the file
85
+ module_name = os.path.splitext(os.path.basename(search_space))[0]
86
+ spec = importlib.util.spec_from_file_location(
87
+ module_name, search_space
88
+ )
89
+ module = importlib.util.module_from_spec(spec)
90
+ spec.loader.exec_module(module)
91
+ else:
92
+ # load the module using importlib
93
+ module = importlib.import_module(search_space)
94
+
95
+ # try to get the 'space' attribute from the module
96
+ try:
97
+ space = module.space
98
+ except AttributeError:
99
+ raise ValueError(f"Module {module.__name__} has no space dictionary")
100
+
101
+ if not isinstance(space, dict):
102
+ raise TypeError(
103
+ "Expected search space in module {} to be "
104
+ "a dictionary, found {}".format(module.__name__, type(space))
105
+ )
106
+
107
+ return space
108
+
109
+
110
+ def stop_on_nan(trial_id: str, result: dict) -> bool:
111
+ return math.isnan(result["train_loss"])
112
+
113
+
114
+ class TrainFunc:
115
+ """
116
+ Callable wrapper that takes a `LightningCLI` and executes
117
+ it with the both the `config` passed here at initialization
118
+ time as well as the arguments supplied by a particular
119
+ hyperparameter config. Meant for execution on workers during
120
+ tuning run, which expect a callable that a particular
121
+ hyperparameter config as its only argument.
122
+
123
+ All runs of the function will be given the same
124
+ Weights & Biases group name of `name` for tracking.
125
+ The names of individual runs in this group will be
126
+ randomly chosen by W&B.
127
+ """
128
+
129
+ def __init__(
130
+ self,
131
+ cli: Type[LightningCLI],
132
+ name: str,
133
+ config: dict,
134
+ callbacks: Optional[list[pl.callbacks.Callback]] = None,
135
+ ) -> None:
136
+ self.cli = cli
137
+ self.name = name
138
+ self.config = config
139
+ self.callbacks = callbacks
140
+
141
+ def validate_logger(self, args):
142
+ try:
143
+ logger = self.config["trainer"]["logger"]
144
+ except KeyError:
145
+ logger = None
146
+
147
+ if logger is not None:
148
+ if "WandbLogger" not in logger:
149
+ raise ValueError(
150
+ "Only W&B logging is supported for distributed tuning"
151
+ )
152
+ args.append(f"--trainer.logger.group={self.name}")
153
+ return args
154
+
155
+ def __call__(self, hparams: dict[str, Any]):
156
+ """
157
+ Dump the config to a file, then parse it
158
+ along with the hyperparameter configuration
159
+ passed here using our CLI.
160
+ """
161
+
162
+ with NamedTemporaryFile(mode="w") as f:
163
+ # dump the core config,
164
+ # then add the hyperparameters
165
+ yaml.dump(self.config, f)
166
+ args = ["-c", f.name]
167
+ for key, value in hparams.items():
168
+ args.append(f"--{key}={value}")
169
+
170
+ args = self.validate_logger(args)
171
+
172
+ cli_cls = get_worker_cli(self.cli, self.callbacks)
173
+ cli = cli_cls(
174
+ run=False, args=args, save_config_kwargs={"overwrite": True}
175
+ )
176
+
177
+ log_dir = cli.trainer.logger.log_dir or cli.trainer.logger.save_dir
178
+ if not log_dir.startswith("s3://"):
179
+ ckpt_prefix = ""
180
+ else:
181
+ ckpt_prefix = "s3://"
182
+
183
+ # restore from checkpoint if available
184
+ checkpoint = train.get_checkpoint()
185
+ ckpt_path = None
186
+ if checkpoint:
187
+ ckpt_path = os.path.join(
188
+ ckpt_prefix, checkpoint.path, "checkpoint.ckpt"
189
+ )
190
+
191
+ trainer = prepare_trainer(cli.trainer)
192
+ trainer.fit(cli.model, cli.datamodule, ckpt_path=ckpt_path)
193
+
194
+
195
+ def configure_deployment(
196
+ train_func: TrainFunc,
197
+ metric_name: str,
198
+ workers_per_trial: int,
199
+ gpus_per_worker: int,
200
+ cpus_per_gpu: int,
201
+ objective: str = "max",
202
+ storage_dir: Optional[str] = None,
203
+ fs: Optional[pyarrow.fs.FileSystem] = None,
204
+ ) -> TorchTrainer:
205
+ """
206
+ Set up a training function that can be distributed
207
+ among the workers in a ray cluster.
208
+
209
+ Args:
210
+ train_func:
211
+ Function that each worker will execute
212
+ with a config specifying the hyperparameter
213
+ configuration for that trial.
214
+ metric_name:
215
+ Name of the metric that will be optimized
216
+ during the hyperparameter search
217
+ workers_per_trial:
218
+ Number of training workers to deploy
219
+ gpus_per_worker:
220
+ Number of GPUs to train over within each worker
221
+ cpus_per_gpu:
222
+ Number of CPUs to attach to each GPU. If gpus_per_worker
223
+ is 0, this is interpretated as the number of CPUs per worker
224
+ objective:
225
+ `"max"` or `"min"`, indicating how the indicated
226
+ metric ought to be optimized
227
+ storage_dir:
228
+ Directory to save ray checkpoints and logs
229
+ during training.
230
+ fs: Filesystem to use for storage
231
+ """
232
+
233
+ if gpus_per_worker == 0:
234
+ cpus_per_worker = cpus_per_gpu
235
+ else:
236
+ cpus_per_worker = cpus_per_gpu * gpus_per_worker
237
+
238
+ use_gpu = gpus_per_worker > 0
239
+ scaling_config = ScalingConfig(
240
+ trainer_resources={"CPU": 0},
241
+ resources_per_worker={"CPU": cpus_per_worker, "GPU": gpus_per_worker},
242
+ num_workers=workers_per_trial,
243
+ use_gpu=use_gpu,
244
+ )
245
+
246
+ run_config = RunConfig(
247
+ checkpoint_config=CheckpointConfig(
248
+ num_to_keep=2,
249
+ checkpoint_score_attribute=metric_name,
250
+ checkpoint_score_order=objective,
251
+ ),
252
+ failure_config=FailureConfig(
253
+ max_failures=5,
254
+ ),
255
+ storage_filesystem=fs,
256
+ storage_path=storage_dir,
257
+ name=train_func.name,
258
+ stop=stop_on_nan,
259
+ )
260
+ return TorchTrainer(
261
+ train_func,
262
+ scaling_config=scaling_config,
263
+ run_config=run_config,
264
+ )
265
+
266
+
267
+ def parse_args(
268
+ cli_cls: Type[LightningCLI], args: Optional[list[str]] = None
269
+ ) -> dict:
270
+ """
271
+ Use a `LightningCLI` class to parse command line arguments
272
+ """
273
+ host_cli = get_host_cli(cli_cls)
274
+ host_cli = host_cli(run=False, args=args)
275
+ config = host_cli.parser.dump(host_cli.config, format="yaml")
276
+ config = yaml.safe_load(config)
277
+ return config
@@ -0,0 +1,96 @@
1
+ Metadata-Version: 2.1
2
+ Name: lightray
3
+ Version: 0.1.0
4
+ Summary: Distribute a LightningCLI hyperparameter search with Ray Tune
5
+ License: MIT
6
+ Author: Ethan Jacob Marx
7
+ Author-email: ethan.marx@ligo.org
8
+ Requires-Python: >=3.9,<4.0
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.9
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Requires-Dist: jsonargparse[signatures] (>=4.29,<5.0)
16
+ Requires-Dist: lightning (>=2.4.0,<3.0.0)
17
+ Requires-Dist: pyarrow (>=17.0.0,<18.0.0)
18
+ Requires-Dist: ray[default,tune] (>=2.8.0,<3.0.0)
19
+ Requires-Dist: s3fs (>=2024.9.0,<2025.0.0)
20
+ Description-Content-Type: text/markdown
21
+
22
+ # lightray
23
+ Easily integrate a `LightningCLI` with `RayTune` hyperparameter optimization
24
+
25
+ ## Getting Started
26
+ Extend your custom `LightningCLI` to a `Trainable` compatible with `RayTune`.
27
+
28
+ Imagine you have a `LightningCLI` parser for a
29
+
30
+ ```python
31
+ from lightning.pytorch as pl
32
+
33
+ class CustomCLI(pl.cli.LightningCLI):
34
+ def add_arguments_to_parser(self, parser):
35
+ parser.link_arguments(
36
+ "data.init_args.parameter",
37
+ "model.init_args.parameter",
38
+ apply_on="parse"
39
+ )
40
+
41
+ class DataModule(pl.LightningDataModule):
42
+ def __init__(self, hidden_dim: int, learning_rate: float, parameter: int):
43
+ self.parameter = parameter
44
+ self.hidden_dim = hidden_dim
45
+ self.learning_rate = learning_rate
46
+
47
+ def train_dataloader(self):
48
+ ...
49
+
50
+ class DataModule(pl.LightningModule):
51
+ def __init__(self, parameter: int):
52
+ self.parameter = parameter
53
+
54
+ def training_step(self):
55
+ ...
56
+
57
+ ```
58
+
59
+ Launching a hyperparameter tuning job with `RayTune` using this `LightningCLI` is as simple as
60
+
61
+ ```python
62
+ from ray import tune
63
+ from raylightning import run
64
+
65
+ # define search space
66
+ search_space = {
67
+ "model.init_args.hidden_dim": tune.choice([32, 64])
68
+ "model.init_args.learning_rate": tune.loguniform(1e-4, 1e-2)
69
+ }
70
+
71
+ # define scheduler
72
+ scheduler = ASHAScheduler(max_t=50, grace_period=1, reduction_factor=2)
73
+
74
+ # pass command line style arguments as if you were
75
+ # running your `LightningCLI` from the command line
76
+ args = ["--config", "/path/to/config.yaml"]
77
+ results = run(
78
+ args=args
79
+ cli_cls=simple_cli,
80
+ name="tune-test",
81
+ metric="val_loss",
82
+ objective="min",
83
+ search_space=search_space,
84
+ scheduler=scheduler,
85
+ storage_dir=storage_dir,
86
+ address=None,
87
+ num_samples=num_samples,
88
+ workers_per_trial=1,
89
+ gpus_per_worker=1.0,
90
+ cpus_per_gpu=4.0,
91
+ temp_dir=None,
92
+ )
93
+ ```
94
+
95
+ s3 storage works out of the box. Make sure you have set the `AWS_ENDPOINT_URL`, `AWS_ACCESS_KEY_ID`, and `AWS_SECRET_ACCESS_KEY` environment variables set. Then, simply pass an s3 path (e.g. `s3://{bucket}/{folder}` to the `storage_dir` argument.
96
+
@@ -0,0 +1,8 @@
1
+ lightray/__init__.py,sha256=JgTN3Hzek3t8ejv76E8ffQSuptc3PYtEHjTKinsc9ks,22
2
+ lightray/callbacks.py,sha256=YaTNoz-KP1hS2ZjWs09_0De1SCT-f10-u5NMN_Otivw,2024
3
+ lightray/fs.py,sha256=3WTWtWue8L1rFNusmtJTNrnRQWzFYXJHlf4yjN9Ewgc,1461
4
+ lightray/tune.py,sha256=xmDj6mdKe1xjOY6ya5USWsTJKx9_iSDlux56Z232734,3409
5
+ lightray/utils.py,sha256=qNtWoReHTVSCQJ5hUHJGcci_VmwJnBVyHJ7phHb6pOk,8407
6
+ lightray-0.1.0.dist-info/METADATA,sha256=6onMoD9_uafK11YnUctjFYetjRTF7uAv3Eo9YlPKLpI,2919
7
+ lightray-0.1.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
8
+ lightray-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 1.9.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any