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/experiment.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from cortexgrid import s3_util
|
|
8
|
+
from cortexgrid.infra import get_mlflow_tracking_uri
|
|
9
|
+
from cortexgrid.jobs import stop_experiment_run_jobs
|
|
10
|
+
from cortexgrid.ray_util import list_ray_jobs_with_submission_id, stop_ray_job
|
|
11
|
+
from cortexgrid.model_storage import delete_models_for_run
|
|
12
|
+
from haikunator import Haikunator # type: ignore
|
|
13
|
+
from mlflow.tracking import MlflowClient
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger(__name__)
|
|
17
|
+
_SINGLETON_EXPERIMENT: Experiment | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Experiment:
|
|
22
|
+
experiment_name: str
|
|
23
|
+
run_id: str
|
|
24
|
+
|
|
25
|
+
def run_name(self) -> str:
|
|
26
|
+
client = MlflowClient(tracking_uri=get_mlflow_tracking_uri())
|
|
27
|
+
return client.get_run(self.run_id).info.run_name or self.run_id
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def init(cls, name: str | None = None) -> "Experiment":
|
|
31
|
+
"""Create a new MLflow experiment+run. Once per process."""
|
|
32
|
+
logging.basicConfig(
|
|
33
|
+
level=logging.INFO,
|
|
34
|
+
format="%(asctime)s.%(msecs)03d %(levelname)s %(name)s: %(message)s",
|
|
35
|
+
datefmt="%H:%M:%S",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
if _SINGLETON_EXPERIMENT is not None:
|
|
39
|
+
if name is not None and _SINGLETON_EXPERIMENT.experiment_name != name:
|
|
40
|
+
raise ValueError(
|
|
41
|
+
f"Active experiment has a different name {name} != "
|
|
42
|
+
f"{_SINGLETON_EXPERIMENT.experiment_name}"
|
|
43
|
+
)
|
|
44
|
+
return _SINGLETON_EXPERIMENT
|
|
45
|
+
|
|
46
|
+
experiment_name, run_id = _try_create_experiment_and_run(
|
|
47
|
+
experiment=name,
|
|
48
|
+
mlflow_tracking_uri=get_mlflow_tracking_uri(),
|
|
49
|
+
)
|
|
50
|
+
instance = cls(
|
|
51
|
+
experiment_name=experiment_name,
|
|
52
|
+
run_id=run_id,
|
|
53
|
+
)
|
|
54
|
+
set_instance(instance)
|
|
55
|
+
return instance
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def from_experiment(cls, experiment_name: str, run_id: str) -> "Experiment":
|
|
59
|
+
"""Bind to an existing MLflow experiment+run. Once per process."""
|
|
60
|
+
logging.basicConfig(
|
|
61
|
+
level=logging.INFO,
|
|
62
|
+
format="%(asctime)s.%(msecs)03d %(levelname)s %(name)s: %(message)s",
|
|
63
|
+
datefmt="%H:%M:%S",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if _SINGLETON_EXPERIMENT is not None:
|
|
67
|
+
if (
|
|
68
|
+
_SINGLETON_EXPERIMENT.experiment_name != experiment_name
|
|
69
|
+
or _SINGLETON_EXPERIMENT.run_id != run_id
|
|
70
|
+
):
|
|
71
|
+
raise ValueError(
|
|
72
|
+
f"Active experiment is different to the requested one: "
|
|
73
|
+
f"({experiment_name}, {run_id}) != "
|
|
74
|
+
f"({_SINGLETON_EXPERIMENT.experiment_name}, {_SINGLETON_EXPERIMENT.run_id})"
|
|
75
|
+
)
|
|
76
|
+
return _SINGLETON_EXPERIMENT
|
|
77
|
+
|
|
78
|
+
instance = cls(
|
|
79
|
+
experiment_name=experiment_name,
|
|
80
|
+
run_id=run_id,
|
|
81
|
+
)
|
|
82
|
+
set_instance(instance)
|
|
83
|
+
return instance
|
|
84
|
+
|
|
85
|
+
def get_jobs(self) -> list[str]:
|
|
86
|
+
"""Return cortexgrid job IDs submitted against this experiment+run."""
|
|
87
|
+
client = MlflowClient(tracking_uri=get_mlflow_tracking_uri())
|
|
88
|
+
return [
|
|
89
|
+
Path(f.path).name
|
|
90
|
+
for f in client.list_artifacts(self.run_id, path="job")
|
|
91
|
+
if f.is_dir
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def get_instance(cls) -> "Experiment":
|
|
96
|
+
if _SINGLETON_EXPERIMENT is None:
|
|
97
|
+
raise ValueError("Call Experiment.init or Experiment.from_experiment first")
|
|
98
|
+
return _SINGLETON_EXPERIMENT
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def close(cls) -> None:
|
|
102
|
+
"""Detach the active experiment so a different one can be init'd in this process."""
|
|
103
|
+
set_instance(None)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def set_instance(instance: Experiment | None) -> None:
|
|
107
|
+
global _SINGLETON_EXPERIMENT
|
|
108
|
+
_SINGLETON_EXPERIMENT = instance
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def clear_instance() -> None:
|
|
112
|
+
# Use only in tests to clean between tests
|
|
113
|
+
global _SINGLETON_EXPERIMENT
|
|
114
|
+
_SINGLETON_EXPERIMENT = None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _try_create_experiment_and_run(
|
|
118
|
+
experiment: str | None, mlflow_tracking_uri: str
|
|
119
|
+
) -> tuple[str, str]:
|
|
120
|
+
name_gen = Haikunator()
|
|
121
|
+
if experiment is None:
|
|
122
|
+
experiment = name_gen.haikunate(token_length=2, token_chars="0123456789")
|
|
123
|
+
|
|
124
|
+
client = MlflowClient(tracking_uri=mlflow_tracking_uri)
|
|
125
|
+
experiment_obj = client.get_experiment_by_name(name=experiment)
|
|
126
|
+
if experiment_obj:
|
|
127
|
+
experiment_id = experiment_obj.experiment_id
|
|
128
|
+
else:
|
|
129
|
+
experiment_id = client.create_experiment(name=experiment)
|
|
130
|
+
|
|
131
|
+
run_name = name_gen.haikunate(token_length=2, token_chars="0123456789")
|
|
132
|
+
run = client.create_run(experiment_id=experiment_id, run_name=run_name)
|
|
133
|
+
|
|
134
|
+
return (experiment, run.info.run_id)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def delete_run(run_id: str) -> None:
|
|
138
|
+
"""Soft-delete a run in MLflow, cancel its Ray attempts, and wipe its
|
|
139
|
+
S3 job packages so it cannot be relaunched or re-read.
|
|
140
|
+
|
|
141
|
+
stop_experiment_run_jobs runs first so the control plane stops spawning
|
|
142
|
+
fresh Ray attempts for retry=True jobs before we tear the run down."""
|
|
143
|
+
log.info("delete_run(%s): start", run_id)
|
|
144
|
+
stop_experiment_run_jobs(run_id)
|
|
145
|
+
log.info("delete_run(%s): stop_experiment_run_jobs done", run_id)
|
|
146
|
+
client = MlflowClient(tracking_uri=get_mlflow_tracking_uri())
|
|
147
|
+
job_ids = [
|
|
148
|
+
Path(f.path).name for f in client.list_artifacts(run_id, path="job") if f.is_dir
|
|
149
|
+
]
|
|
150
|
+
log.info("delete_run(%s): %d job artifact(s) to clean", run_id, len(job_ids))
|
|
151
|
+
all_submissions = list_ray_jobs_with_submission_id()
|
|
152
|
+
for job_id in job_ids:
|
|
153
|
+
prefix = f"{run_id}-{job_id}-"
|
|
154
|
+
for sid in all_submissions:
|
|
155
|
+
if sid.startswith(prefix):
|
|
156
|
+
stop_ray_job(sid)
|
|
157
|
+
s3_util.delete_prefix(f"job/{job_id}/")
|
|
158
|
+
delete_models_for_run(run_id)
|
|
159
|
+
client.delete_run(run_id)
|
|
160
|
+
log.info("delete_run(%s): done", run_id)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def list_run_ids_in_experiment(name: str) -> list[str]:
|
|
164
|
+
"""Return the run IDs of every active run in the named experiment."""
|
|
165
|
+
client = MlflowClient(tracking_uri=get_mlflow_tracking_uri())
|
|
166
|
+
exp = client.get_experiment_by_name(name)
|
|
167
|
+
if exp is None:
|
|
168
|
+
return []
|
|
169
|
+
return [
|
|
170
|
+
r.info.run_id for r in client.search_runs(experiment_ids=[exp.experiment_id])
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def delete_experiment(name: str) -> None:
|
|
175
|
+
"""Soft-delete every run in the experiment, then the experiment itself.
|
|
176
|
+
|
|
177
|
+
Idempotent: already-deleted experiments are treated as success."""
|
|
178
|
+
log.info("delete_experiment(%r): start", name)
|
|
179
|
+
client = MlflowClient(tracking_uri=get_mlflow_tracking_uri())
|
|
180
|
+
exp = client.get_experiment_by_name(name)
|
|
181
|
+
if exp is None:
|
|
182
|
+
log.info("delete_experiment(%r): early-exit, experiment not found", name)
|
|
183
|
+
return
|
|
184
|
+
if exp.lifecycle_stage != "active":
|
|
185
|
+
log.info(
|
|
186
|
+
"delete_experiment(%r): early-exit, lifecycle=%s", name, exp.lifecycle_stage
|
|
187
|
+
)
|
|
188
|
+
return
|
|
189
|
+
runs = list(client.search_runs(experiment_ids=[exp.experiment_id]))
|
|
190
|
+
log.info("delete_experiment(%r): %d active run(s) to delete", name, len(runs))
|
|
191
|
+
for run in runs:
|
|
192
|
+
delete_run(run.info.run_id)
|
|
193
|
+
client.delete_experiment(exp.experiment_id)
|
|
194
|
+
log.info("delete_experiment(%r): done", name)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def list_experiments() -> list[Experiment]:
|
|
198
|
+
"""Map MLflow experiment names to their run IDs."""
|
|
199
|
+
client = MlflowClient(tracking_uri=get_mlflow_tracking_uri())
|
|
200
|
+
result: list[Experiment] = []
|
|
201
|
+
for exp in client.search_experiments():
|
|
202
|
+
runs = client.search_runs(experiment_ids=[exp.experiment_id])
|
|
203
|
+
for run in runs:
|
|
204
|
+
result.append(Experiment(exp.name, run_id=run.info.run_id))
|
|
205
|
+
return result
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def get_experiment_by_run_name(run_name: str) -> Experiment:
|
|
209
|
+
"""Resolve a run by its haikunator name back to its (experiment_name, run_id) pair."""
|
|
210
|
+
client = MlflowClient(tracking_uri=get_mlflow_tracking_uri())
|
|
211
|
+
experiment_ids = [e.experiment_id for e in client.search_experiments()]
|
|
212
|
+
if not experiment_ids:
|
|
213
|
+
raise ValueError(f"No run named {run_name!r}")
|
|
214
|
+
runs = client.search_runs(
|
|
215
|
+
experiment_ids=experiment_ids,
|
|
216
|
+
filter_string=f"attributes.run_name = '{run_name}'",
|
|
217
|
+
max_results=1,
|
|
218
|
+
)
|
|
219
|
+
if not runs:
|
|
220
|
+
raise ValueError(f"No run named {run_name!r}")
|
|
221
|
+
run = runs[0]
|
|
222
|
+
exp = client.get_experiment(run.info.experiment_id)
|
|
223
|
+
return Experiment(experiment_name=exp.name, run_id=run.info.run_id)
|
cortexgrid/infra.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from cortexgrid.secrets import get_secret
|
|
2
|
+
from mlflow.tracking import MlflowClient
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def get_mlflow_tracking_uri() -> str:
|
|
6
|
+
return get_secret("MLFLOW_TRACKING_URI")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_ray_job_server_uri() -> str:
|
|
10
|
+
return get_secret("RAY_JOB_SERVER_URI")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_ray_serve_uri() -> str:
|
|
14
|
+
"""HTTP base URL where Ray Serve apps are reachable (data plane, port 8000)."""
|
|
15
|
+
return get_secret("RAY_SERVE_URI")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_ray_serve_applications_uri() -> str:
|
|
19
|
+
"""Dashboard REST endpoint for declarative Serve app management."""
|
|
20
|
+
return f"{get_ray_job_server_uri()}/api/serve/applications/"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_s3_endpoint_url() -> str:
|
|
24
|
+
# AWS profile: regional s3.amazonaws.com URL (stored as "" = no override).
|
|
25
|
+
# On-prem: tailnet-reachable MinIO NodePort URL.
|
|
26
|
+
return get_secret("S3_ENDPOINT_URL")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_s3_bucket() -> str:
|
|
30
|
+
# Provisioned by terraform/platform/s3 on AWS; created lazily on first
|
|
31
|
+
# upload against on-prem MinIO.
|
|
32
|
+
return get_secret("S3_BUCKET_NAME")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_mlflow_run_url(run_id: str) -> str:
|
|
36
|
+
base = get_mlflow_tracking_uri()
|
|
37
|
+
client = MlflowClient(tracking_uri=base)
|
|
38
|
+
run = client.get_run(run_id)
|
|
39
|
+
return f"{base}/#/experiments/{run.info.experiment_id}/runs/{run_id}"
|
cortexgrid/jobs.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""Submit tasks to the cortexgrid control plane."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import cloudpickle # type: ignore
|
|
6
|
+
from dataclasses import asdict, dataclass, field
|
|
7
|
+
import inspect
|
|
8
|
+
import io
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import sys
|
|
13
|
+
import tarfile
|
|
14
|
+
import tempfile
|
|
15
|
+
from typing import Any, Callable
|
|
16
|
+
|
|
17
|
+
from cortexgrid import s3_util
|
|
18
|
+
from cortexgrid._bundle import bundle, stage, worker_provides
|
|
19
|
+
from cortexgrid.infra import get_mlflow_tracking_uri
|
|
20
|
+
from cortexgrid.ray_util import get_ray_job_id_for_cortexgrid_job
|
|
21
|
+
from haikunator import Haikunator # type: ignore
|
|
22
|
+
from mlflow.tracking import MlflowClient
|
|
23
|
+
from pydantic import BaseModel, ConfigDict
|
|
24
|
+
|
|
25
|
+
log = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class LifecycleEvent:
|
|
30
|
+
"""A single observed state on a given attempt of a job.
|
|
31
|
+
|
|
32
|
+
``start`` and ``end`` are ISO 8601 timestamps. ``end`` is ``None``
|
|
33
|
+
while the state is still current; it is set when a subsequent
|
|
34
|
+
observation shows the (attempt, state) pair has changed.
|
|
35
|
+
``ray_job_id`` is the Ray submission id observed at record time and
|
|
36
|
+
is ``None`` for the pre-submission PENDING entry of a given attempt.
|
|
37
|
+
``error`` carries a worker-submission exception message and is
|
|
38
|
+
attached to the event that was current when the worker failed.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
attempt: int
|
|
42
|
+
state: str
|
|
43
|
+
start: str
|
|
44
|
+
end: str | None = None
|
|
45
|
+
ray_job_id: str | None = None
|
|
46
|
+
error: str | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class JobLifecycle:
|
|
51
|
+
"""Static identity and latches for a job.
|
|
52
|
+
|
|
53
|
+
JobLifecycle is the source of truth for *job identity* and for a
|
|
54
|
+
handful of fields that are either immutable or can only change once
|
|
55
|
+
over the lifetime of a job. Live execution status is never stored
|
|
56
|
+
here — it is derived on demand from Ray by :func:`get_ray_job_status`.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
experiment_name: str
|
|
60
|
+
run_id: str
|
|
61
|
+
job_id: str
|
|
62
|
+
stop_requested: bool = False # latch: False -> True, never cleared
|
|
63
|
+
retry: bool = False # static flag set at job creation
|
|
64
|
+
num_gpus: int = 0
|
|
65
|
+
num_cpus: int = 1
|
|
66
|
+
history: list[LifecycleEvent] = field(default_factory=list)
|
|
67
|
+
|
|
68
|
+
def to_json(self) -> str:
|
|
69
|
+
return json.dumps(asdict(self))
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def from_json(cls, text: str) -> "JobLifecycle":
|
|
73
|
+
data = json.loads(text)
|
|
74
|
+
data.pop("error", None)
|
|
75
|
+
data["history"] = [LifecycleEvent(**e) for e in data.get("history", [])]
|
|
76
|
+
return cls(**data)
|
|
77
|
+
|
|
78
|
+
def get_ray_job_id(
|
|
79
|
+
self, all_ray_submission_ids: list[str] | None = None
|
|
80
|
+
) -> str | None:
|
|
81
|
+
return get_ray_job_id_for_cortexgrid_job(
|
|
82
|
+
self.run_id, self.job_id, all_ray_submission_ids
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def download_project_code_root(self) -> str:
|
|
86
|
+
client = MlflowClient(tracking_uri=get_mlflow_tracking_uri())
|
|
87
|
+
manifest_rel = f"job/{self.job_id}/manifest.json"
|
|
88
|
+
if not any(
|
|
89
|
+
a.path == manifest_rel
|
|
90
|
+
for a in client.list_artifacts(self.run_id, f"job/{self.job_id}")
|
|
91
|
+
):
|
|
92
|
+
raise FileNotFoundError(
|
|
93
|
+
f"artifact {manifest_rel} not found in run {self.run_id}"
|
|
94
|
+
)
|
|
95
|
+
manifest_path = client.download_artifacts(self.run_id, manifest_rel)
|
|
96
|
+
manifest = json.loads(Path(manifest_path).read_text())
|
|
97
|
+
_, _, src_path = (
|
|
98
|
+
manifest["code_tarball_uri"].removeprefix("s3://").partition("/")
|
|
99
|
+
)
|
|
100
|
+
extract_dir = Path(tempfile.mkdtemp())
|
|
101
|
+
tarball_local = s3_util.download(
|
|
102
|
+
src_path, local_path=str(extract_dir / "project_code_root.tar.gz")
|
|
103
|
+
)
|
|
104
|
+
with tarfile.open(tarball_local, "r:gz") as tar:
|
|
105
|
+
tar.extractall(extract_dir)
|
|
106
|
+
return str(extract_dir / "project_code_root")
|
|
107
|
+
|
|
108
|
+
def save_to_mlflow(self) -> None:
|
|
109
|
+
artifact_path = f"job/{self.job_id}"
|
|
110
|
+
client = MlflowClient(tracking_uri=get_mlflow_tracking_uri())
|
|
111
|
+
log.info(
|
|
112
|
+
"Saving lifecycle for job %s (stop_requested=%s)",
|
|
113
|
+
self.job_id,
|
|
114
|
+
self.stop_requested,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
118
|
+
local_path = Path(tmp_dir, "lifecycle.json")
|
|
119
|
+
local_path.write_text(self.to_json())
|
|
120
|
+
client.log_artifact(
|
|
121
|
+
self.run_id, str(local_path), artifact_path=artifact_path
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
@classmethod
|
|
125
|
+
def load_from_mlflow(cls, run_id: str, job_id: str) -> "JobLifecycle":
|
|
126
|
+
client = MlflowClient(tracking_uri=get_mlflow_tracking_uri())
|
|
127
|
+
lifecycle_rel = f"job/{job_id}/lifecycle.json"
|
|
128
|
+
if not any(
|
|
129
|
+
a.path == lifecycle_rel
|
|
130
|
+
for a in client.list_artifacts(run_id, f"job/{job_id}")
|
|
131
|
+
):
|
|
132
|
+
raise FileNotFoundError(
|
|
133
|
+
f"artifact {lifecycle_rel} not found in run {run_id}"
|
|
134
|
+
)
|
|
135
|
+
local_path = client.download_artifacts(run_id, lifecycle_rel)
|
|
136
|
+
return cls.from_json(Path(local_path).read_text())
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class Payload(BaseModel):
|
|
140
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
141
|
+
|
|
142
|
+
experiment_name: str
|
|
143
|
+
run_id: str
|
|
144
|
+
job_id: str
|
|
145
|
+
fn: Callable[..., Any]
|
|
146
|
+
args: tuple[Any, ...]
|
|
147
|
+
kwargs: dict[str, Any]
|
|
148
|
+
project_code_root: str
|
|
149
|
+
num_gpus: int = 0
|
|
150
|
+
num_cpus: int = 1
|
|
151
|
+
|
|
152
|
+
def save_to_mlflow(self) -> None:
|
|
153
|
+
artifact_path = f"job/{self.job_id}"
|
|
154
|
+
client = MlflowClient(tracking_uri=get_mlflow_tracking_uri())
|
|
155
|
+
log.info(
|
|
156
|
+
"Uploading payload for job %s from %s", self.job_id, self.project_code_root
|
|
157
|
+
)
|
|
158
|
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
159
|
+
tarball_path = Path(tmp_dir, "project_code_root.tar.gz")
|
|
160
|
+
payload_bytes = cloudpickle.dumps(self)
|
|
161
|
+
with tarfile.open(tarball_path, "w:gz") as tar:
|
|
162
|
+
tar.add(self.project_code_root, arcname="project_code_root")
|
|
163
|
+
info = tarfile.TarInfo("project_code_root/payload.pkl")
|
|
164
|
+
info.size = len(payload_bytes)
|
|
165
|
+
tar.addfile(info, io.BytesIO(payload_bytes))
|
|
166
|
+
tarball_uri = s3_util.upload(
|
|
167
|
+
str(tarball_path),
|
|
168
|
+
dest_path=f"{artifact_path}/project_code_root.tar.gz",
|
|
169
|
+
)
|
|
170
|
+
manifest_path = Path(tmp_dir, "manifest.json")
|
|
171
|
+
manifest_path.write_text(json.dumps({"code_tarball_uri": tarball_uri}))
|
|
172
|
+
client.log_artifact(
|
|
173
|
+
self.run_id, str(manifest_path), artifact_path=artifact_path
|
|
174
|
+
)
|
|
175
|
+
log.info("Payload upload complete for job %s", self.job_id)
|
|
176
|
+
|
|
177
|
+
@classmethod
|
|
178
|
+
def load_from_mlflow(cls, run_id: str, job_id: str) -> "Payload":
|
|
179
|
+
client = MlflowClient(tracking_uri=get_mlflow_tracking_uri())
|
|
180
|
+
log.info("Downloading payload for job %s", job_id)
|
|
181
|
+
manifest_rel = f"job/{job_id}/manifest.json"
|
|
182
|
+
if not any(
|
|
183
|
+
a.path == manifest_rel
|
|
184
|
+
for a in client.list_artifacts(run_id, f"job/{job_id}")
|
|
185
|
+
):
|
|
186
|
+
raise FileNotFoundError(
|
|
187
|
+
f"artifact {manifest_rel} not found in run {run_id}"
|
|
188
|
+
)
|
|
189
|
+
manifest_path = client.download_artifacts(run_id, manifest_rel)
|
|
190
|
+
manifest = json.loads(Path(manifest_path).read_text())
|
|
191
|
+
_, _, src_path = (
|
|
192
|
+
manifest["code_tarball_uri"].removeprefix("s3://").partition("/")
|
|
193
|
+
)
|
|
194
|
+
extract_dir = Path(tempfile.mkdtemp())
|
|
195
|
+
tarball_local = s3_util.download(
|
|
196
|
+
src_path, local_path=str(extract_dir / "project_code_root.tar.gz")
|
|
197
|
+
)
|
|
198
|
+
with tarfile.open(tarball_local, "r:gz") as tar:
|
|
199
|
+
tar.extractall(extract_dir)
|
|
200
|
+
project_code_root = str(extract_dir / "project_code_root")
|
|
201
|
+
sys.path.insert(0, project_code_root)
|
|
202
|
+
try:
|
|
203
|
+
payload = cloudpickle.loads(
|
|
204
|
+
Path(project_code_root, "payload.pkl").read_bytes()
|
|
205
|
+
)
|
|
206
|
+
finally:
|
|
207
|
+
sys.path.remove(project_code_root)
|
|
208
|
+
payload.project_code_root = project_code_root
|
|
209
|
+
log.info(
|
|
210
|
+
"Payload downloaded for job %s, project_code_root=%s",
|
|
211
|
+
job_id,
|
|
212
|
+
payload.project_code_root,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
return payload
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def schedule_remote_job(
|
|
219
|
+
experiment_name: str,
|
|
220
|
+
run_id: str,
|
|
221
|
+
fn: Callable[..., Any],
|
|
222
|
+
*args: Any,
|
|
223
|
+
num_gpus: int = 0,
|
|
224
|
+
num_cpus: int = 1,
|
|
225
|
+
retry: bool = False,
|
|
226
|
+
**kwargs: Any,
|
|
227
|
+
) -> str:
|
|
228
|
+
"""Submit a function to the control plane. Returns a job ID."""
|
|
229
|
+
job_id = Haikunator().haikunate(token_length=2, token_chars="0123456789")
|
|
230
|
+
entry_file = Path(inspect.getfile(fn)).resolve()
|
|
231
|
+
driver_file = Path(__file__).with_name("_ray_job_driver.py")
|
|
232
|
+
files = bundle(entry_file).merge(bundle(driver_file)).local_files - worker_provides()
|
|
233
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
234
|
+
code_root = Path(tmp, "project_code_root")
|
|
235
|
+
stage(files, code_root)
|
|
236
|
+
log.info("Submitting job %s (%d files)", job_id, len(files))
|
|
237
|
+
Payload(
|
|
238
|
+
experiment_name=experiment_name,
|
|
239
|
+
run_id=run_id,
|
|
240
|
+
job_id=job_id,
|
|
241
|
+
fn=fn,
|
|
242
|
+
args=args,
|
|
243
|
+
kwargs=kwargs,
|
|
244
|
+
project_code_root=str(code_root),
|
|
245
|
+
num_gpus=num_gpus,
|
|
246
|
+
num_cpus=num_cpus,
|
|
247
|
+
).save_to_mlflow()
|
|
248
|
+
JobLifecycle(
|
|
249
|
+
experiment_name=experiment_name,
|
|
250
|
+
run_id=run_id,
|
|
251
|
+
job_id=job_id,
|
|
252
|
+
retry=retry,
|
|
253
|
+
num_gpus=num_gpus,
|
|
254
|
+
num_cpus=num_cpus,
|
|
255
|
+
).save_to_mlflow()
|
|
256
|
+
return job_id
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def list_experiment_run_jobs(run_id: str) -> list[JobLifecycle]:
|
|
260
|
+
"""Return all jobs and their lifecycle states for this experiment+run."""
|
|
261
|
+
client = MlflowClient(tracking_uri=get_mlflow_tracking_uri())
|
|
262
|
+
entries = client.list_artifacts(run_id, path="job")
|
|
263
|
+
result: list[JobLifecycle] = []
|
|
264
|
+
for entry in entries:
|
|
265
|
+
if not entry.is_dir:
|
|
266
|
+
continue
|
|
267
|
+
job_id = Path(entry.path).name
|
|
268
|
+
try:
|
|
269
|
+
result.append(JobLifecycle.load_from_mlflow(run_id, job_id))
|
|
270
|
+
except Exception:
|
|
271
|
+
logging.getLogger(__name__).warning(
|
|
272
|
+
"Skipping job %s: missing lifecycle", job_id
|
|
273
|
+
)
|
|
274
|
+
return result
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def stop_experiment_run_jobs(run_id: str) -> None:
|
|
278
|
+
"""Request all jobs in the run to stop by flipping the stop_requested latch.
|
|
279
|
+
|
|
280
|
+
This function never touches Ray. The control plane observes the
|
|
281
|
+
latch on its next poll and calls `ray.stop_job` for any job that
|
|
282
|
+
has reached Ray. For jobs that have not yet been submitted, the
|
|
283
|
+
latch short-circuits the submission path in the worker.
|
|
284
|
+
|
|
285
|
+
Idempotent: already-requested jobs are skipped, and the flag has
|
|
286
|
+
no effect on jobs that Ray already reports as terminal.
|
|
287
|
+
"""
|
|
288
|
+
for job in list_experiment_run_jobs(run_id):
|
|
289
|
+
if job.stop_requested:
|
|
290
|
+
continue
|
|
291
|
+
job.stop_requested = True
|
|
292
|
+
job.save_to_mlflow()
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""MLflow wrappers.
|
|
2
|
+
|
|
3
|
+
Thin layer that configures MLflow tracking URI and S3 credentials,
|
|
4
|
+
then exposes convenience functions for common operations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
|
|
14
|
+
from cortexgrid.experiment import Experiment
|
|
15
|
+
from cortexgrid.infra import get_mlflow_tracking_uri
|
|
16
|
+
from mlflow.entities import Metric
|
|
17
|
+
from mlflow.tracking import MlflowClient
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def log_metric(key: str, value: float, step: int | None = None) -> None:
|
|
21
|
+
"""Log a metric to the current active MLflow run."""
|
|
22
|
+
experiment = Experiment.get_instance()
|
|
23
|
+
|
|
24
|
+
client = get_mlflow_client()
|
|
25
|
+
client.log_metric(experiment.run_id, key, value, step=step)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def log_metrics(metrics: dict[str, float], step: int | None = None) -> None:
|
|
29
|
+
"""Log multiple metrics to the current active MLflow run."""
|
|
30
|
+
experiment = Experiment.get_instance()
|
|
31
|
+
|
|
32
|
+
client = get_mlflow_client()
|
|
33
|
+
timestamp = int(time.time() * 1000)
|
|
34
|
+
metric_entities = [
|
|
35
|
+
Metric(key=k, value=v, timestamp=timestamp, step=step or 0)
|
|
36
|
+
for k, v in metrics.items()
|
|
37
|
+
]
|
|
38
|
+
client.log_batch(experiment.run_id, metrics=metric_entities)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def log_params(params: dict[str, Any]) -> None:
|
|
42
|
+
"""Log parameters to the current active MLflow run."""
|
|
43
|
+
experiment = Experiment.get_instance()
|
|
44
|
+
|
|
45
|
+
client = get_mlflow_client()
|
|
46
|
+
for key, value in params.items():
|
|
47
|
+
client.log_param(experiment.run_id, key, value)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def log_artifact(local_path: str, artifact_path: str | None = None) -> None:
|
|
51
|
+
"""Log a file as an artifact to the current active MLflow run."""
|
|
52
|
+
experiment = Experiment.get_instance()
|
|
53
|
+
|
|
54
|
+
client = get_mlflow_client()
|
|
55
|
+
client.log_artifact(experiment.run_id, local_path, artifact_path=artifact_path)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_mlflow_client() -> MlflowClient:
|
|
59
|
+
"""Return a configured MlflowClient."""
|
|
60
|
+
return MlflowClient(tracking_uri=get_mlflow_tracking_uri())
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def list_run_metrics(run_id: str) -> list[str]:
|
|
64
|
+
"""Return the metric key names logged for a run."""
|
|
65
|
+
client = get_mlflow_client()
|
|
66
|
+
run = client.get_run(run_id)
|
|
67
|
+
return list(run.data.metrics.keys())
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_metric_history(
|
|
71
|
+
run_id: str, key: str, max_points: int | None = None
|
|
72
|
+
) -> list[dict[str, Any]]:
|
|
73
|
+
"""Return the history of a metric as [{step, value, timestamp}, ...].
|
|
74
|
+
|
|
75
|
+
When `max_points` is given, MLflow samples server-side and returns at
|
|
76
|
+
most that many points (saves DB work and bytes-on-the-wire). Without
|
|
77
|
+
`max_points`, the full history is returned.
|
|
78
|
+
"""
|
|
79
|
+
if max_points is not None:
|
|
80
|
+
url = (
|
|
81
|
+
f"{get_mlflow_tracking_uri().rstrip('/')}"
|
|
82
|
+
"/ajax-api/2.0/mlflow/metrics/get-history-bulk-interval"
|
|
83
|
+
)
|
|
84
|
+
response = requests.get(
|
|
85
|
+
url,
|
|
86
|
+
params={"run_ids": run_id, "metric_key": key, "max_results": max_points},
|
|
87
|
+
)
|
|
88
|
+
response.raise_for_status()
|
|
89
|
+
return [
|
|
90
|
+
{"step": int(m["step"]), "value": float(m["value"]), "timestamp": int(m["timestamp"])}
|
|
91
|
+
for m in response.json().get("metrics", [])
|
|
92
|
+
]
|
|
93
|
+
client = get_mlflow_client()
|
|
94
|
+
history = client.get_metric_history(run_id, key)
|
|
95
|
+
return [
|
|
96
|
+
{"step": m.step, "value": m.value, "timestamp": m.timestamp} for m in history
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def list_run_params(run_id: str) -> dict[str, str]:
|
|
101
|
+
"""Return all parameter key/value pairs for a run."""
|
|
102
|
+
client = get_mlflow_client()
|
|
103
|
+
run = client.get_run(run_id)
|
|
104
|
+
return dict(run.data.params)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def list_run_artifacts(run_id: str, path: str = "") -> list[str]:
|
|
108
|
+
"""Return artifact paths for a run."""
|
|
109
|
+
client = get_mlflow_client()
|
|
110
|
+
return [a.path for a in client.list_artifacts(run_id, path=path)]
|