cortexgrid 0.2.85__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.
- cortexgrid/__init__.py +195 -0
- cortexgrid/_bundle.py +207 -0
- cortexgrid/_ray_job_driver.py +48 -0
- cortexgrid/_serve_entry.py +39 -0
- cortexgrid/checkpoint.py +217 -0
- cortexgrid/experiment.py +223 -0
- cortexgrid/infra.py +39 -0
- cortexgrid/jobs.py +292 -0
- cortexgrid/mlflow_util.py +110 -0
- cortexgrid/model_serving.py +365 -0
- cortexgrid/model_storage.py +255 -0
- cortexgrid/py.typed +0 -0
- cortexgrid/ray_util.py +171 -0
- cortexgrid/s3_util.py +135 -0
- cortexgrid/secrets.py +56 -0
- cortexgrid-0.2.85.dist-info/METADATA +199 -0
- cortexgrid-0.2.85.dist-info/RECORD +19 -0
- cortexgrid-0.2.85.dist-info/WHEEL +4 -0
- cortexgrid-0.2.85.dist-info/licenses/LICENSE +202 -0
cortexgrid/__init__.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""cortexgrid — connect your code to the RoboLab compute cluster.
|
|
2
|
+
|
|
3
|
+
import cortexgrid
|
|
4
|
+
|
|
5
|
+
# Fire-and-forget training on the DGX; returns a job id immediately.
|
|
6
|
+
# The jobs control plane picks up the submission and dispatches it to Ray.
|
|
7
|
+
job_id = cortexgrid.remote(my_train, config, num_gpus=1, retry=True)
|
|
8
|
+
print(f"Submitted: {job_id}")
|
|
9
|
+
|
|
10
|
+
# Check on it later
|
|
11
|
+
for job in cortexgrid.list_experiment_run_jobs(run_id):
|
|
12
|
+
status = cortexgrid.get_ray_job_status(job.get_ray_job_id())
|
|
13
|
+
|
|
14
|
+
# Inside the training function — checkpoint after each epoch
|
|
15
|
+
with cortexgrid.checkpoint() as ckpt:
|
|
16
|
+
ckpt.epoch = epoch
|
|
17
|
+
ckpt.save_training_state(model, optimizer, scheduler)
|
|
18
|
+
|
|
19
|
+
# On resume — load checkpoint if it exists
|
|
20
|
+
ckpt = cortexgrid.resume()
|
|
21
|
+
if ckpt:
|
|
22
|
+
ckpt.restore_training_state(model, optimizer, scheduler)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, Callable
|
|
29
|
+
|
|
30
|
+
from cortexgrid.checkpoint import checkpoint, resume
|
|
31
|
+
from cortexgrid.experiment import (
|
|
32
|
+
Experiment,
|
|
33
|
+
delete_experiment,
|
|
34
|
+
delete_run,
|
|
35
|
+
list_experiments,
|
|
36
|
+
)
|
|
37
|
+
from cortexgrid.infra import get_ray_job_server_uri
|
|
38
|
+
from cortexgrid.jobs import (
|
|
39
|
+
schedule_remote_job,
|
|
40
|
+
list_experiment_run_jobs,
|
|
41
|
+
stop_experiment_run_jobs,
|
|
42
|
+
JobLifecycle,
|
|
43
|
+
LifecycleEvent,
|
|
44
|
+
Payload,
|
|
45
|
+
)
|
|
46
|
+
from cortexgrid.secrets import (
|
|
47
|
+
delete_secret,
|
|
48
|
+
get_secret,
|
|
49
|
+
list_secrets,
|
|
50
|
+
set_secret,
|
|
51
|
+
)
|
|
52
|
+
from cortexgrid.mlflow_util import (
|
|
53
|
+
log_metric,
|
|
54
|
+
log_metrics,
|
|
55
|
+
log_params,
|
|
56
|
+
log_artifact,
|
|
57
|
+
get_mlflow_client,
|
|
58
|
+
list_run_metrics,
|
|
59
|
+
get_metric_history,
|
|
60
|
+
list_run_params,
|
|
61
|
+
list_run_artifacts,
|
|
62
|
+
)
|
|
63
|
+
from cortexgrid.ray_util import (
|
|
64
|
+
get_ray_status,
|
|
65
|
+
get_ray_logs,
|
|
66
|
+
get_ray_job_url,
|
|
67
|
+
stop_ray_job,
|
|
68
|
+
submit_ray_job,
|
|
69
|
+
list_ray_jobs_with_submission_id,
|
|
70
|
+
get_ray_job_status,
|
|
71
|
+
ray_submission_id,
|
|
72
|
+
get_ray_job_attempt,
|
|
73
|
+
JobStatus,
|
|
74
|
+
)
|
|
75
|
+
from cortexgrid.s3_util import delete_prefix, download, get_s3_client, upload, upload_dir
|
|
76
|
+
from cortexgrid.model_storage import (
|
|
77
|
+
SavedModel,
|
|
78
|
+
delete_model,
|
|
79
|
+
list_models,
|
|
80
|
+
load_model,
|
|
81
|
+
model_registry_status,
|
|
82
|
+
)
|
|
83
|
+
from cortexgrid.model_storage import save_model as _save_model_storage
|
|
84
|
+
from cortexgrid.model_serving import (
|
|
85
|
+
Deployment,
|
|
86
|
+
ServingStatus,
|
|
87
|
+
deploy_model,
|
|
88
|
+
list_deployed_models,
|
|
89
|
+
model_serving_status,
|
|
90
|
+
undeploy_model,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def remote(
|
|
95
|
+
fn: Callable[..., Any],
|
|
96
|
+
*args: Any,
|
|
97
|
+
num_gpus: int = 0,
|
|
98
|
+
num_cpus: int = 1,
|
|
99
|
+
retry: bool = False,
|
|
100
|
+
**kwargs: Any,
|
|
101
|
+
) -> str:
|
|
102
|
+
"""Submit a function to the control plane. Returns a job ID."""
|
|
103
|
+
experiment = Experiment.get_instance()
|
|
104
|
+
return schedule_remote_job(
|
|
105
|
+
experiment.experiment_name,
|
|
106
|
+
experiment.run_id,
|
|
107
|
+
fn,
|
|
108
|
+
*args,
|
|
109
|
+
num_gpus=num_gpus,
|
|
110
|
+
num_cpus=num_cpus,
|
|
111
|
+
retry=retry,
|
|
112
|
+
**kwargs,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def save_model(
|
|
117
|
+
weights_dir: str | Path, serve_app: type, family: str, suffix: str
|
|
118
|
+
) -> SavedModel:
|
|
119
|
+
"""Persist a weights directory under the current Experiment's run, paired
|
|
120
|
+
with the serve-app class that will front it at deploy time."""
|
|
121
|
+
experiment = Experiment.get_instance()
|
|
122
|
+
return _save_model_storage(
|
|
123
|
+
weights_dir,
|
|
124
|
+
serve_app,
|
|
125
|
+
suffix,
|
|
126
|
+
family,
|
|
127
|
+
run_id=experiment.run_id,
|
|
128
|
+
run_name=experiment.run_name(),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
__all__ = [
|
|
133
|
+
"Experiment",
|
|
134
|
+
"delete_experiment",
|
|
135
|
+
"delete_run",
|
|
136
|
+
# Ray / jobs
|
|
137
|
+
"remote",
|
|
138
|
+
"get_ray_job_status",
|
|
139
|
+
"list_experiment_run_jobs",
|
|
140
|
+
"stop_experiment_run_jobs",
|
|
141
|
+
"JobStatus",
|
|
142
|
+
"JobLifecycle",
|
|
143
|
+
"LifecycleEvent",
|
|
144
|
+
"Payload",
|
|
145
|
+
"get_ray_status",
|
|
146
|
+
"get_ray_logs",
|
|
147
|
+
"get_ray_job_url",
|
|
148
|
+
"stop_ray_job",
|
|
149
|
+
"submit_ray_job",
|
|
150
|
+
"get_ray_job_server_uri",
|
|
151
|
+
"list_ray_jobs_with_submission_id",
|
|
152
|
+
"ray_submission_id",
|
|
153
|
+
"get_ray_job_attempt",
|
|
154
|
+
# MLflow
|
|
155
|
+
"log_metric",
|
|
156
|
+
"log_metrics",
|
|
157
|
+
"log_params",
|
|
158
|
+
"log_artifact",
|
|
159
|
+
"get_mlflow_client",
|
|
160
|
+
"list_run_metrics",
|
|
161
|
+
"get_metric_history",
|
|
162
|
+
"list_run_params",
|
|
163
|
+
"list_run_artifacts",
|
|
164
|
+
"list_experiments",
|
|
165
|
+
# Checkpointing
|
|
166
|
+
"checkpoint",
|
|
167
|
+
"resume",
|
|
168
|
+
# S3
|
|
169
|
+
"upload",
|
|
170
|
+
"upload_dir",
|
|
171
|
+
"download",
|
|
172
|
+
"get_s3_client",
|
|
173
|
+
"delete_prefix",
|
|
174
|
+
# Secrets
|
|
175
|
+
"get_secret",
|
|
176
|
+
"set_secret",
|
|
177
|
+
"list_secrets",
|
|
178
|
+
"delete_secret",
|
|
179
|
+
# Model registry
|
|
180
|
+
"SavedModel",
|
|
181
|
+
"save_model",
|
|
182
|
+
"load_model",
|
|
183
|
+
"list_models",
|
|
184
|
+
"model_registry_status",
|
|
185
|
+
"delete_model",
|
|
186
|
+
# Model serving
|
|
187
|
+
"Deployment",
|
|
188
|
+
"ServingStatus",
|
|
189
|
+
"deploy_model",
|
|
190
|
+
"model_serving_status",
|
|
191
|
+
"undeploy_model",
|
|
192
|
+
"list_deployed_models",
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
# trigger: 315-trigger-tests-2026-05-16
|
cortexgrid/_bundle.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Code bundler: the files needed to run a piece of Python on another machine.
|
|
2
|
+
|
|
3
|
+
`bundle(seed)` describes what is needed to run the module at `seed`: the local
|
|
4
|
+
files it reaches -- following its import graph and each package's __init__
|
|
5
|
+
chain, resolving imports the way the interpreter does -- and the third-party
|
|
6
|
+
dependencies those files import. A file is local unless it lives in an installed
|
|
7
|
+
package location (site-packages / dist-packages); imports that resolve into one
|
|
8
|
+
are not followed. The standard library is excluded (it ships with the
|
|
9
|
+
interpreter). Bundles of several seeds combine with `BundleDesc.merge`.
|
|
10
|
+
|
|
11
|
+
`stage(files, dest)` lays a bundle out under `dest` at each file's import path,
|
|
12
|
+
so `dest` on sys.path (e.g. a Ray working_dir) makes every module importable.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import ast
|
|
18
|
+
from collections.abc import Iterator
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
import functools
|
|
21
|
+
import importlib.machinery
|
|
22
|
+
import importlib.util
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
import shutil
|
|
25
|
+
import sys
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
ThirdPartyDependencyName = str
|
|
29
|
+
ThirdPartyDependencyVersion = str
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class BundleDesc:
|
|
33
|
+
local_files: set[Path]
|
|
34
|
+
tp_deps: dict[ThirdPartyDependencyName, ThirdPartyDependencyVersion]
|
|
35
|
+
|
|
36
|
+
def merge(self, other: BundleDesc) -> BundleDesc:
|
|
37
|
+
return BundleDesc(
|
|
38
|
+
local_files=self.local_files.union(other.local_files),
|
|
39
|
+
tp_deps={**self.tp_deps, **other.tp_deps},
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def bundle(seed: Path) -> BundleDesc:
|
|
44
|
+
"""What is needed to run the module at `seed`: its local files, each at its
|
|
45
|
+
real path. Third-party dependency detection is not implemented yet, so
|
|
46
|
+
`tp_deps` is always empty."""
|
|
47
|
+
seed = seed.resolve()
|
|
48
|
+
files: set[Path] = set()
|
|
49
|
+
queue: list[Path] = [seed]
|
|
50
|
+
while queue:
|
|
51
|
+
file = queue.pop()
|
|
52
|
+
if file in files or not _is_local(file):
|
|
53
|
+
continue
|
|
54
|
+
files.add(file)
|
|
55
|
+
queue.extend(_init_chain(file)) # importing a module runs its __init__ chain
|
|
56
|
+
if file.suffix == ".py":
|
|
57
|
+
for name in _imports(file):
|
|
58
|
+
dep = _module_file(name)
|
|
59
|
+
if dep is not None:
|
|
60
|
+
queue.append(dep)
|
|
61
|
+
return BundleDesc(local_files=files, tp_deps={})
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def stage(files: set[Path], dest: Path) -> None:
|
|
65
|
+
"""Copy `files` under `dest`, each at its import path (relative to the
|
|
66
|
+
sys.path entry it lives under), so `dest` on sys.path imports them all."""
|
|
67
|
+
for file in files:
|
|
68
|
+
target = dest / file.relative_to(_sys_path_root(file))
|
|
69
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
shutil.copy2(file, target)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# Distributions already present in the Ray worker image (k8s/docker/ray/Dockerfile).
|
|
74
|
+
# They and their whole dependency trees -- torch's CUDA stack, sympy, numpy, ray,
|
|
75
|
+
# mlflow, ... -- are on the worker already, so a caller subtracts them from a
|
|
76
|
+
# bundle rather than shipping them again.
|
|
77
|
+
_WORKER_BAKED = ("ray", "mlflow", "torch", "smart_open", "dotenv", "psutil")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@functools.lru_cache(maxsize=1)
|
|
81
|
+
def worker_provides() -> frozenset[Path]:
|
|
82
|
+
"""Every file the Ray worker image already provides. Subtract from a bundle
|
|
83
|
+
before shipping: `bundle(entry).local_files - worker_provides()`."""
|
|
84
|
+
provided: set[Path] = set()
|
|
85
|
+
for name in _WORKER_BAKED:
|
|
86
|
+
origin = _module_file(name)
|
|
87
|
+
if origin is not None:
|
|
88
|
+
provided |= bundle(origin).local_files
|
|
89
|
+
return frozenset(provided)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _is_local(file: Path) -> bool:
|
|
93
|
+
"""True unless `file` lives in an installed-package location."""
|
|
94
|
+
return not any(part in ("site-packages", "dist-packages") for part in file.parts)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _module_file(name: str) -> Path | None:
|
|
98
|
+
"""The file the interpreter would load for import `name`; None for the
|
|
99
|
+
standard library, builtins, namespace packages, and names that are not
|
|
100
|
+
modules (e.g. a function in `from pkg import function`)."""
|
|
101
|
+
if name.split(".")[0] in sys.stdlib_module_names:
|
|
102
|
+
return None
|
|
103
|
+
spec = _find_spec(name)
|
|
104
|
+
if spec is None or spec.origin in (None, "built-in", "frozen"):
|
|
105
|
+
return None
|
|
106
|
+
return Path(spec.origin).resolve()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _find_spec(name: str) -> importlib.machinery.ModuleSpec | None:
|
|
110
|
+
"""The spec the import system would find for `name`, without importing any
|
|
111
|
+
of its parent packages (importlib.util.find_spec imports them).
|
|
112
|
+
|
|
113
|
+
Mirrors the import system's own lookup: each meta path finder is asked for
|
|
114
|
+
`name`, with the parent's submodule_search_locations as the import path for
|
|
115
|
+
a submodule. https://docs.python.org/3.11/reference/import.html#the-meta-path
|
|
116
|
+
"""
|
|
117
|
+
parent, _, child = name.rpartition(".")
|
|
118
|
+
path: list[str] | None = None
|
|
119
|
+
if parent:
|
|
120
|
+
parent_spec = _find_spec(parent)
|
|
121
|
+
if parent_spec is None or parent_spec.submodule_search_locations is None:
|
|
122
|
+
return None
|
|
123
|
+
path = list(parent_spec.submodule_search_locations)
|
|
124
|
+
portions = _namespace_portions(child, path)
|
|
125
|
+
if portions:
|
|
126
|
+
# The finders cannot build a nested namespace package's spec without
|
|
127
|
+
# its parent in sys.modules, so build it here.
|
|
128
|
+
spec = importlib.machinery.ModuleSpec(name, None, is_package=True)
|
|
129
|
+
spec.submodule_search_locations = portions
|
|
130
|
+
return spec
|
|
131
|
+
for finder in sys.meta_path:
|
|
132
|
+
spec = finder.find_spec(name, path)
|
|
133
|
+
if spec is not None:
|
|
134
|
+
return spec
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _namespace_portions(child: str, path: list[str]) -> list[str]:
|
|
139
|
+
"""The directories that make `child` a namespace package under `path`: each
|
|
140
|
+
`<entry>/<child>` directory, provided no entry holds `child` as a module or
|
|
141
|
+
regular package. Empty if `child` is not a namespace package.
|
|
142
|
+
https://peps.python.org/pep-0420/#specification
|
|
143
|
+
"""
|
|
144
|
+
suffixes = importlib.machinery.all_suffixes()
|
|
145
|
+
portions: list[str] = []
|
|
146
|
+
for entry in map(Path, path):
|
|
147
|
+
directory = entry / child
|
|
148
|
+
if any(
|
|
149
|
+
(entry / f"{child}{suffix}").is_file()
|
|
150
|
+
or (directory / f"__init__{suffix}").is_file()
|
|
151
|
+
for suffix in suffixes
|
|
152
|
+
):
|
|
153
|
+
return []
|
|
154
|
+
if directory.is_dir():
|
|
155
|
+
portions.append(str(directory))
|
|
156
|
+
return portions
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _imports(file: Path) -> Iterator[str]:
|
|
160
|
+
"""Absolute module names imported by a .py file, relative imports resolved
|
|
161
|
+
against the file's own package."""
|
|
162
|
+
package = _package(file)
|
|
163
|
+
for node in ast.walk(ast.parse(file.read_text())):
|
|
164
|
+
if isinstance(node, ast.Import):
|
|
165
|
+
for alias in node.names:
|
|
166
|
+
yield alias.name
|
|
167
|
+
elif isinstance(node, ast.ImportFrom):
|
|
168
|
+
if node.level:
|
|
169
|
+
try:
|
|
170
|
+
base = importlib.util.resolve_name(
|
|
171
|
+
"." * node.level + (node.module or ""), package
|
|
172
|
+
)
|
|
173
|
+
except (ImportError, ValueError):
|
|
174
|
+
continue
|
|
175
|
+
else:
|
|
176
|
+
base = node.module
|
|
177
|
+
if base:
|
|
178
|
+
yield base
|
|
179
|
+
for alias in node.names:
|
|
180
|
+
yield f"{base}.{alias.name}"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _init_chain(file: Path) -> Iterator[Path]:
|
|
184
|
+
"""The package __init__.py files above `file`, up to its sys.path root."""
|
|
185
|
+
directory = file.parent
|
|
186
|
+
while (directory / "__init__.py").exists():
|
|
187
|
+
yield (directory / "__init__.py").resolve()
|
|
188
|
+
directory = directory.parent
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _package(file: Path) -> str:
|
|
192
|
+
"""Dotted name of the package `file` lives in ('' at the sys.path root)."""
|
|
193
|
+
parts: list[str] = []
|
|
194
|
+
directory = file.parent
|
|
195
|
+
while (directory / "__init__.py").exists():
|
|
196
|
+
parts.insert(0, directory.name)
|
|
197
|
+
directory = directory.parent
|
|
198
|
+
return ".".join(parts)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _sys_path_root(file: Path) -> Path:
|
|
202
|
+
"""The sys.path entry `file` is imported from: its first ancestor directory
|
|
203
|
+
without an __init__.py."""
|
|
204
|
+
directory = file.parent
|
|
205
|
+
while (directory / "__init__.py").exists():
|
|
206
|
+
directory = directory.parent
|
|
207
|
+
return directory
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Cluster-side entrypoint. Run by Ray as: python -m cortexgrid._ray_job_driver payload.pkl"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import cloudpickle # type: ignore
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from cortexgrid.checkpoint import set_cortexgrid_job_id
|
|
11
|
+
from cortexgrid.experiment import Experiment
|
|
12
|
+
from cortexgrid.jobs import Payload
|
|
13
|
+
|
|
14
|
+
log = logging.getLogger("ray-job-driver")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main(payload_path: str) -> None:
|
|
18
|
+
logging.basicConfig(
|
|
19
|
+
level=logging.INFO,
|
|
20
|
+
format="%(asctime)s.%(msecs)03d %(levelname)s %(name)s: %(message)s",
|
|
21
|
+
datefmt="%H:%M:%S",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
log.info("Loading payload: %s", payload_path)
|
|
25
|
+
|
|
26
|
+
if not Path(payload_path).exists():
|
|
27
|
+
raise FileNotFoundError(f"Payload not found: {payload_path}")
|
|
28
|
+
|
|
29
|
+
payload: Payload = cloudpickle.loads(Path(payload_path).read_bytes())
|
|
30
|
+
log.info("Payload loaded: %s", payload_path)
|
|
31
|
+
log.info("Loading experiment: %s/%s", payload.experiment_name, payload.run_id)
|
|
32
|
+
|
|
33
|
+
set_cortexgrid_job_id(payload.job_id)
|
|
34
|
+
Experiment.from_experiment(payload.experiment_name, payload.run_id)
|
|
35
|
+
log.info("Experiment loaded: %s/%s", payload.experiment_name, payload.run_id)
|
|
36
|
+
|
|
37
|
+
log.info(
|
|
38
|
+
"Starting job in experiment: %s/%s; args: %r; kwargs: %r",
|
|
39
|
+
payload.experiment_name,
|
|
40
|
+
payload.run_id,
|
|
41
|
+
payload.args,
|
|
42
|
+
payload.kwargs,
|
|
43
|
+
)
|
|
44
|
+
payload.fn(*payload.args, **payload.kwargs)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
if __name__ == "__main__":
|
|
48
|
+
main(sys.argv[1])
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Generic Ray Serve application builder used by every cortexgrid.deploy_model.
|
|
2
|
+
|
|
3
|
+
Ray Serve's REST `import_path` resolves to `cortexgrid._serve_entry:build`.
|
|
4
|
+
On the cluster replica, `build` imports the serve-app class bundled at
|
|
5
|
+
`save_model` time (its import path was stored as an MLflow tag), reads its
|
|
6
|
+
`num_gpus`/`num_replicas` class attributes for actor placement, wraps it as a
|
|
7
|
+
Ray Serve deployment, and binds it with the (family, suffix, run_name)
|
|
8
|
+
identifiers.
|
|
9
|
+
|
|
10
|
+
The serve-app owns everything about traffic: its own routes, request schemas,
|
|
11
|
+
streaming, and timeouts. cortexgrid does not interpose a request/response
|
|
12
|
+
contract - it only schedules the app and hands it the identifiers it needs to
|
|
13
|
+
fetch its own weights via `cortexgrid.load_model`.
|
|
14
|
+
|
|
15
|
+
Design note: resource needs (`num_gpus`/`num_replicas`) are read from plain
|
|
16
|
+
class attributes rather than a cortexgrid decorator or base class. This is a
|
|
17
|
+
deliberate, provisional choice - kept minimal until we see how serve-apps
|
|
18
|
+
declare resources in practice; revisit if plain class attributes prove too
|
|
19
|
+
limited.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import importlib
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from ray import serve
|
|
28
|
+
from ray.serve.deployment import Application
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build(args: dict[str, Any]) -> Application:
|
|
32
|
+
module_name, class_name = args["class_import_path"].split(":")
|
|
33
|
+
serve_app = getattr(importlib.import_module(module_name), class_name)
|
|
34
|
+
num_gpus = getattr(serve_app, "num_gpus", 0)
|
|
35
|
+
num_replicas = getattr(serve_app, "num_replicas", 1)
|
|
36
|
+
return serve.deployment(serve_app).options(
|
|
37
|
+
num_replicas=num_replicas,
|
|
38
|
+
ray_actor_options={"num_gpus": num_gpus},
|
|
39
|
+
).bind(args["family"], args["suffix"], args["run_name"])
|
cortexgrid/checkpoint.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Durable checkpointing for cortexgrid jobs.
|
|
2
|
+
|
|
3
|
+
Save arbitrary state (primitives, torch tensors, state_dicts) via MLflow
|
|
4
|
+
artifacts and resume from the latest checkpoint on retry.
|
|
5
|
+
|
|
6
|
+
Usage (save)::
|
|
7
|
+
|
|
8
|
+
with cortexgrid.checkpoint() as ckpt:
|
|
9
|
+
ckpt.epoch = epoch
|
|
10
|
+
ckpt.global_step = step
|
|
11
|
+
ckpt.save_training_state(model, optimizer, scheduler)
|
|
12
|
+
|
|
13
|
+
Usage (resume)::
|
|
14
|
+
|
|
15
|
+
ckpt = cortexgrid.resume()
|
|
16
|
+
if ckpt:
|
|
17
|
+
ckpt.restore_training_state(model, optimizer, scheduler)
|
|
18
|
+
start_epoch = ckpt.epoch + 1
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
import tempfile
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
import cloudpickle # type: ignore
|
|
30
|
+
from mlflow.tracking import MlflowClient
|
|
31
|
+
|
|
32
|
+
from cortexgrid import s3_util
|
|
33
|
+
from cortexgrid.experiment import Experiment, get_mlflow_tracking_uri
|
|
34
|
+
|
|
35
|
+
log = logging.getLogger(__name__)
|
|
36
|
+
_CORTEXGRID_JOB_ID: str | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Checkpoint:
|
|
40
|
+
"""Attribute-based checkpoint persisted via MLflow artifacts.
|
|
41
|
+
|
|
42
|
+
Assign any cloudpickle-compatible value to an attribute and it will be
|
|
43
|
+
saved when the context manager exits::
|
|
44
|
+
|
|
45
|
+
with cortexgrid.checkpoint() as ckpt:
|
|
46
|
+
ckpt.epoch = 5
|
|
47
|
+
ckpt.model_state = model.state_dict()
|
|
48
|
+
|
|
49
|
+
Read values back after calling ``cortexgrid.resume()``::
|
|
50
|
+
|
|
51
|
+
ckpt = cortexgrid.resume()
|
|
52
|
+
if ckpt:
|
|
53
|
+
model.load_state_dict(ckpt.model_state)
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
_INTERNAL = frozenset(("_prefix", "_data"))
|
|
57
|
+
|
|
58
|
+
def __init__(self, prefix: str, *, _data: dict[str, Any] | None = None) -> None:
|
|
59
|
+
object.__setattr__(self, "_prefix", prefix)
|
|
60
|
+
object.__setattr__(self, "_data", _data if _data is not None else {})
|
|
61
|
+
|
|
62
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
|
63
|
+
if name in self._INTERNAL:
|
|
64
|
+
object.__setattr__(self, name, value)
|
|
65
|
+
else:
|
|
66
|
+
self._data[name] = value
|
|
67
|
+
|
|
68
|
+
def __getattr__(self, name: str) -> Any:
|
|
69
|
+
if name in ("_prefix", "_data", "_INTERNAL"):
|
|
70
|
+
return object.__getattribute__(self, name)
|
|
71
|
+
try:
|
|
72
|
+
return self._data[name]
|
|
73
|
+
except KeyError:
|
|
74
|
+
raise AttributeError(f"Checkpoint has no attribute {name!r}")
|
|
75
|
+
|
|
76
|
+
def __bool__(self) -> bool:
|
|
77
|
+
return bool(self._data)
|
|
78
|
+
|
|
79
|
+
def __repr__(self) -> str:
|
|
80
|
+
keys = ", ".join(sorted(self._data))
|
|
81
|
+
return f"Checkpoint(prefix={self._prefix!r}, attrs=[{keys}])"
|
|
82
|
+
|
|
83
|
+
def save_training_state(
|
|
84
|
+
self,
|
|
85
|
+
model: Any,
|
|
86
|
+
optimizer: Any,
|
|
87
|
+
scheduler: Any | None = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Save model, optimizer, and optionally scheduler state_dicts."""
|
|
90
|
+
self._data["_model_state"] = model.state_dict()
|
|
91
|
+
self._data["_optimizer_state"] = optimizer.state_dict()
|
|
92
|
+
if scheduler is not None:
|
|
93
|
+
self._data["_scheduler_state"] = scheduler.state_dict()
|
|
94
|
+
|
|
95
|
+
def restore_training_state(
|
|
96
|
+
self,
|
|
97
|
+
model: Any,
|
|
98
|
+
optimizer: Any,
|
|
99
|
+
scheduler: Any | None = None,
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Load state_dicts back into existing model/optimizer/scheduler."""
|
|
102
|
+
model.load_state_dict(self._data["_model_state"])
|
|
103
|
+
optimizer.load_state_dict(self._data["_optimizer_state"])
|
|
104
|
+
if scheduler is not None and "_scheduler_state" in self._data:
|
|
105
|
+
scheduler.load_state_dict(self._data["_scheduler_state"])
|
|
106
|
+
|
|
107
|
+
def __enter__(self) -> Checkpoint:
|
|
108
|
+
return self
|
|
109
|
+
|
|
110
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
111
|
+
if exc_type is None:
|
|
112
|
+
self._persist()
|
|
113
|
+
|
|
114
|
+
def _persist(self) -> None:
|
|
115
|
+
"""Upload attr blobs to MinIO; log manifest.json via MLflow."""
|
|
116
|
+
exp = Experiment.get_instance()
|
|
117
|
+
client = MlflowClient(tracking_uri=get_mlflow_tracking_uri())
|
|
118
|
+
tmpdir = Path(tempfile.mkdtemp())
|
|
119
|
+
|
|
120
|
+
manifest: dict[str, Any] = {"attrs": {}}
|
|
121
|
+
|
|
122
|
+
for name, value in self._data.items():
|
|
123
|
+
filename = f"{name}.pkl"
|
|
124
|
+
(tmpdir / filename).write_bytes(cloudpickle.dumps(value))
|
|
125
|
+
uri = s3_util.upload(
|
|
126
|
+
str(tmpdir / filename), dest_path=f"{self._prefix}/{filename}"
|
|
127
|
+
)
|
|
128
|
+
manifest["attrs"][name] = {"uri": uri}
|
|
129
|
+
|
|
130
|
+
(tmpdir / "manifest.json").write_text(json.dumps(manifest))
|
|
131
|
+
client.log_artifact(
|
|
132
|
+
exp.run_id, str(tmpdir / "manifest.json"), artifact_path=self._prefix
|
|
133
|
+
)
|
|
134
|
+
log.info("Checkpoint saved: %s (%d attrs)", self._prefix, len(self._data))
|
|
135
|
+
|
|
136
|
+
@classmethod
|
|
137
|
+
def _load(cls, prefix: str) -> Checkpoint | None:
|
|
138
|
+
"""Download manifest via MLflow; download attr blobs from MinIO."""
|
|
139
|
+
exp = Experiment.get_instance()
|
|
140
|
+
client = MlflowClient(tracking_uri=get_mlflow_tracking_uri())
|
|
141
|
+
|
|
142
|
+
manifest_rel = f"{prefix}/manifest.json"
|
|
143
|
+
if not any(
|
|
144
|
+
a.path == manifest_rel for a in client.list_artifacts(exp.run_id, prefix)
|
|
145
|
+
):
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
manifest_path = client.download_artifacts(exp.run_id, manifest_rel)
|
|
150
|
+
manifest = json.loads(Path(manifest_path).read_text())
|
|
151
|
+
except Exception:
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
data: dict[str, Any] = {}
|
|
155
|
+
tmpdir = Path(tempfile.mkdtemp())
|
|
156
|
+
for name, info in manifest["attrs"].items():
|
|
157
|
+
try:
|
|
158
|
+
_, _, src_path = info["uri"].removeprefix("s3://").partition("/")
|
|
159
|
+
file_path = s3_util.download(
|
|
160
|
+
src_path, local_path=str(tmpdir / Path(src_path).name)
|
|
161
|
+
)
|
|
162
|
+
data[name] = cloudpickle.loads(Path(file_path).read_bytes())
|
|
163
|
+
except Exception:
|
|
164
|
+
log.warning("Failed to load checkpoint attribute %r", name)
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
log.info("Checkpoint loaded: %s (%d attrs)", prefix, len(data))
|
|
168
|
+
return cls(prefix, _data=data)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def set_cortexgrid_job_id(job_id: str) -> None:
|
|
172
|
+
global _CORTEXGRID_JOB_ID
|
|
173
|
+
_CORTEXGRID_JOB_ID = job_id
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def get_cortexgrid_job_id() -> str | None:
|
|
177
|
+
"""Return the current job ID, or None if not running inside a job."""
|
|
178
|
+
global _CORTEXGRID_JOB_ID
|
|
179
|
+
return _CORTEXGRID_JOB_ID
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _checkpoint_prefix() -> str:
|
|
183
|
+
job_id = get_cortexgrid_job_id() or "global"
|
|
184
|
+
return f"checkpoint/{job_id}"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def checkpoint() -> Checkpoint:
|
|
188
|
+
"""Create a checkpoint for the current job. Use as a context manager.
|
|
189
|
+
|
|
190
|
+
Returns a no-op checkpoint if not running inside a cortexgrid job,
|
|
191
|
+
so callers don't need to guard with ``if`` checks.
|
|
192
|
+
|
|
193
|
+
Example::
|
|
194
|
+
|
|
195
|
+
with cortexgrid.checkpoint() as ckpt:
|
|
196
|
+
ckpt.epoch = epoch
|
|
197
|
+
ckpt.save_training_state(model, optimizer, scheduler)
|
|
198
|
+
"""
|
|
199
|
+
prefix = _checkpoint_prefix()
|
|
200
|
+
return Checkpoint(prefix)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def resume() -> Checkpoint | None:
|
|
204
|
+
"""Load the latest checkpoint for the current job, or None.
|
|
205
|
+
|
|
206
|
+
Returns None if not running inside a cortexgrid job or if no
|
|
207
|
+
checkpoint exists.
|
|
208
|
+
|
|
209
|
+
Example::
|
|
210
|
+
|
|
211
|
+
ckpt = cortexgrid.resume()
|
|
212
|
+
if ckpt:
|
|
213
|
+
ckpt.restore_training_state(model, optimizer, scheduler)
|
|
214
|
+
start_epoch = ckpt.epoch + 1
|
|
215
|
+
"""
|
|
216
|
+
prefix = _checkpoint_prefix()
|
|
217
|
+
return Checkpoint._load(prefix)
|