cortexgrid 0.2.95__tar.gz → 0.2.97__tar.gz
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-0.2.95 → cortexgrid-0.2.97}/PKG-INFO +9 -5
- {cortexgrid-0.2.95 → cortexgrid-0.2.97}/cortexgrid/__init__.py +14 -3
- {cortexgrid-0.2.95 → cortexgrid-0.2.97}/cortexgrid/_bundle.py +21 -1
- {cortexgrid-0.2.95 → cortexgrid-0.2.97}/cortexgrid/_serve_entry.py +11 -12
- {cortexgrid-0.2.95 → cortexgrid-0.2.97}/cortexgrid/model_serving.py +189 -33
- {cortexgrid-0.2.95 → cortexgrid-0.2.97}/cortexgrid/model_storage.py +96 -8
- {cortexgrid-0.2.95 → cortexgrid-0.2.97}/docs/cortexgrid/README.md +8 -4
- {cortexgrid-0.2.95 → cortexgrid-0.2.97}/pyproject.toml +1 -1
- {cortexgrid-0.2.95 → cortexgrid-0.2.97}/.gitignore +0 -0
- {cortexgrid-0.2.95 → cortexgrid-0.2.97}/LICENSE +0 -0
- {cortexgrid-0.2.95 → cortexgrid-0.2.97}/cortexgrid/_ray_job_driver.py +0 -0
- {cortexgrid-0.2.95 → cortexgrid-0.2.97}/cortexgrid/checkpoint.py +0 -0
- {cortexgrid-0.2.95 → cortexgrid-0.2.97}/cortexgrid/experiment.py +0 -0
- {cortexgrid-0.2.95 → cortexgrid-0.2.97}/cortexgrid/infra.py +0 -0
- {cortexgrid-0.2.95 → cortexgrid-0.2.97}/cortexgrid/jobs.py +0 -0
- {cortexgrid-0.2.95 → cortexgrid-0.2.97}/cortexgrid/mlflow_util.py +0 -0
- {cortexgrid-0.2.95 → cortexgrid-0.2.97}/cortexgrid/py.typed +0 -0
- {cortexgrid-0.2.95 → cortexgrid-0.2.97}/cortexgrid/ray_util.py +0 -0
- {cortexgrid-0.2.95 → cortexgrid-0.2.97}/cortexgrid/s3_util.py +0 -0
- {cortexgrid-0.2.95 → cortexgrid-0.2.97}/cortexgrid/secrets.py +0 -0
- {cortexgrid-0.2.95 → cortexgrid-0.2.97}/cortexgrid/serve.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: cortexgrid
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.97
|
|
4
4
|
Summary: Connect your ML code to the RoboLab compute cluster — Ray, MLflow, and S3
|
|
5
5
|
Project-URL: Homepage, https://github.com/robodatalab/cortexgrid
|
|
6
6
|
Project-URL: Repository, https://github.com/robodatalab/cortexgrid
|
|
@@ -165,20 +165,24 @@ app = FastAPI()
|
|
|
165
165
|
|
|
166
166
|
@serve.ingress(app)
|
|
167
167
|
class MyServeApp:
|
|
168
|
-
num_gpus = 1
|
|
169
|
-
|
|
170
168
|
def __init__(self, family: str, suffix: str, run_name: str) -> None:
|
|
171
169
|
self._weights_dir = cortexgrid.load_model(family, suffix, run_name)
|
|
172
170
|
|
|
173
171
|
@app.post("/complete")
|
|
174
172
|
async def complete(self, body: dict): ...
|
|
175
173
|
|
|
176
|
-
saved = cortexgrid.save_model(
|
|
174
|
+
saved = cortexgrid.save_model(
|
|
175
|
+
weights_dir, MyServeApp, family="qwen", suffix="instruct",
|
|
176
|
+
# What one replica needs; the model is deployed only on a host that has it.
|
|
177
|
+
requirements=cortexgrid.ModelRequirements(num_gpus=1, ram_gb=8, vram_gb=16),
|
|
178
|
+
)
|
|
177
179
|
deployed = cortexgrid.deploy_model("qwen", "instruct", saved.run_name, wait=True)
|
|
178
180
|
print(deployed.url)
|
|
179
181
|
```
|
|
180
182
|
|
|
181
|
-
|
|
183
|
+
The requirements are part of the model, not of the serve-app class: GPUs, RAM and VRAM (GiB, 0 meaning no requirement) are stored with it and matched against what the cluster's hosts have free. Correct them later with `cortexgrid.set_model_requirements(family, suffix, run_name, requirements)` or on the model card in the dashboard; `cortexgrid.deploy_model(..., num_replicas=2)` chooses how many copies to run.
|
|
184
|
+
|
|
185
|
+
`save_model` saves a new copy under every run - meant for weights the run produced (e.g. a fine-tune). For a model produced elsewhere (e.g. a pretrained base model), `cortexgrid.import_model(source, MyServeApp, family, suffix)` uploads it once under `run_name=cortexgrid.IMPORTED` and on later runs only re-bundles `MyServeApp` if its code changed; deploy it with `deploy_model(family, suffix, cortexgrid.IMPORTED)`.
|
|
182
186
|
|
|
183
187
|
`save_model` is synchronous (registry lifecycle: `uploading` -> `ready`); `deploy_model` schedules the serving lifecycle (`deploying` -> `running`). With `wait=True` a failed deploy raises `cortexgrid.ModelDeployFailed`; `cortexgrid.wait_for_model_serving(family, suffix, run_name, timeout=...)` waits on a deploy started elsewhere, and re-deploying a failed model retries it from scratch. See [model-serving.md](https://github.com/robodatalab/cortexgrid/blob/main/docs/cortexgrid/model-serving.md) for both lifecycles end to end - upload/deploy/undeploy/delete, status queries (`model_registry_status`, `model_serving_status`), and error handling.
|
|
184
188
|
|
|
@@ -80,12 +80,14 @@ from cortexgrid.model_storage import (
|
|
|
80
80
|
list_models,
|
|
81
81
|
load_model,
|
|
82
82
|
model_registry_status,
|
|
83
|
+
set_model_requirements,
|
|
83
84
|
)
|
|
84
85
|
from cortexgrid.model_storage import import_model as _import_model_storage
|
|
85
86
|
from cortexgrid.model_storage import save_model as _save_model_storage
|
|
86
87
|
from cortexgrid.model_serving import (
|
|
87
88
|
Deployment,
|
|
88
89
|
ModelDeployFailed,
|
|
90
|
+
ModelRequirements,
|
|
89
91
|
ServingStatus,
|
|
90
92
|
deploy_model,
|
|
91
93
|
list_deployed_models,
|
|
@@ -118,10 +120,15 @@ def remote(
|
|
|
118
120
|
|
|
119
121
|
|
|
120
122
|
def save_model(
|
|
121
|
-
weights_dir: str | Path,
|
|
123
|
+
weights_dir: str | Path,
|
|
124
|
+
serve_app: type,
|
|
125
|
+
family: str,
|
|
126
|
+
suffix: str,
|
|
127
|
+
requirements: ModelRequirements | None = None,
|
|
122
128
|
) -> SavedModel:
|
|
123
129
|
"""Persist a weights directory under the current Experiment's run, paired
|
|
124
|
-
with the serve-app class that will front it at deploy time
|
|
130
|
+
with the serve-app class that will front it at deploy time and the
|
|
131
|
+
hardware one replica of it needs.
|
|
125
132
|
|
|
126
133
|
Every run saves a new copy under its own run_name - meant for weights the
|
|
127
134
|
run produced (e.g. a fine-tune). For a model produced elsewhere that should
|
|
@@ -134,6 +141,7 @@ def save_model(
|
|
|
134
141
|
family,
|
|
135
142
|
run_id=experiment.run_id,
|
|
136
143
|
run_name=experiment.run_name(),
|
|
144
|
+
requirements=requirements,
|
|
137
145
|
)
|
|
138
146
|
|
|
139
147
|
|
|
@@ -142,6 +150,7 @@ def import_model(
|
|
|
142
150
|
serve_app: type,
|
|
143
151
|
family: str,
|
|
144
152
|
suffix: str,
|
|
153
|
+
requirements: ModelRequirements | None = None,
|
|
145
154
|
) -> SavedModel:
|
|
146
155
|
"""Register a model produced elsewhere once, reuse it on every later call,
|
|
147
156
|
and record on the current Experiment's run which imported model it used.
|
|
@@ -151,7 +160,7 @@ def import_model(
|
|
|
151
160
|
`imported_model/<family>/<suffix>` holds the version's `created_at`, set
|
|
152
161
|
whether this call uploaded the model or reused it."""
|
|
153
162
|
experiment = Experiment.get_instance()
|
|
154
|
-
model = _import_model_storage(source, serve_app, family, suffix)
|
|
163
|
+
model = _import_model_storage(source, serve_app, family, suffix, requirements)
|
|
155
164
|
get_mlflow_client().set_tag(
|
|
156
165
|
experiment.run_id, f"imported_model/{family}/{suffix}", model.created_at
|
|
157
166
|
)
|
|
@@ -213,6 +222,8 @@ __all__ = [
|
|
|
213
222
|
"load_model",
|
|
214
223
|
"list_models",
|
|
215
224
|
"model_registry_status",
|
|
225
|
+
"ModelRequirements",
|
|
226
|
+
"set_model_requirements",
|
|
216
227
|
"delete_model",
|
|
217
228
|
# Model serving
|
|
218
229
|
"Deployment",
|
|
@@ -12,6 +12,8 @@ Bundles of several seeds combine with `BundleDesc.merge`.
|
|
|
12
12
|
|
|
13
13
|
`stage(files, dest)` lays a bundle out under `dest` at each file's import path,
|
|
14
14
|
so `dest` on sys.path (e.g. a Ray working_dir) makes every module importable.
|
|
15
|
+
`digest(files)` hashes that layout, so bundles that stage identically compare
|
|
16
|
+
equal wherever their files live.
|
|
15
17
|
|
|
16
18
|
`BundleDesc.pip_requirements(worker_provides())` pins the third-party
|
|
17
19
|
distributions the Ray worker image does not already have, for a Ray `pip`
|
|
@@ -24,6 +26,7 @@ import ast
|
|
|
24
26
|
from collections.abc import Iterable, Iterator
|
|
25
27
|
from dataclasses import dataclass
|
|
26
28
|
import functools
|
|
29
|
+
import hashlib
|
|
27
30
|
import importlib.machinery
|
|
28
31
|
import importlib.metadata
|
|
29
32
|
import importlib.util
|
|
@@ -105,11 +108,23 @@ def stage(files: set[Path], dest: Path) -> None:
|
|
|
105
108
|
in installed distributions)."""
|
|
106
109
|
dest.mkdir(parents=True, exist_ok=True)
|
|
107
110
|
for file in files:
|
|
108
|
-
target = dest /
|
|
111
|
+
target = dest / _import_path(file)
|
|
109
112
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
110
113
|
shutil.copy2(file, target)
|
|
111
114
|
|
|
112
115
|
|
|
116
|
+
def digest(files: set[Path]) -> str:
|
|
117
|
+
"""SHA-256 of `files` as `stage` lays them out: each file's import path and
|
|
118
|
+
contents, in import-path order. Files that stage identically digest
|
|
119
|
+
identically, wherever they live on disk."""
|
|
120
|
+
sha = hashlib.sha256()
|
|
121
|
+
for path, file in sorted((_import_path(file).as_posix(), file) for file in files):
|
|
122
|
+
content = file.read_bytes()
|
|
123
|
+
sha.update(f"{path}\0{len(content)}\0".encode())
|
|
124
|
+
sha.update(content)
|
|
125
|
+
return sha.hexdigest()
|
|
126
|
+
|
|
127
|
+
|
|
113
128
|
# What the Ray worker image pip-installs, as the Dockerfile spells it. They and
|
|
114
129
|
# their dependency trees are on the worker already, so they are never installed
|
|
115
130
|
# there again -- a second copy in the job's virtualenv would shadow the image's.
|
|
@@ -335,6 +350,11 @@ def _package(file: Path) -> str:
|
|
|
335
350
|
return ".".join(parts)
|
|
336
351
|
|
|
337
352
|
|
|
353
|
+
def _import_path(file: Path) -> Path:
|
|
354
|
+
"""`file` relative to the sys.path entry it is imported from."""
|
|
355
|
+
return file.relative_to(_sys_path_root(file))
|
|
356
|
+
|
|
357
|
+
|
|
338
358
|
def _sys_path_root(file: Path) -> Path:
|
|
339
359
|
"""The sys.path entry `file` is imported from: its first ancestor directory
|
|
340
360
|
without an __init__.py."""
|
|
@@ -4,9 +4,9 @@ Ray Serve's REST `import_path` resolves to `cortexgrid._serve_entry:build`.
|
|
|
4
4
|
On the cluster replica, `build` imports the serve-app class bundled at
|
|
5
5
|
`save_model` time (its import path was stored as an MLflow tag), applies Ray's
|
|
6
6
|
ingress with the app it was marked with by `cortexgrid.serve.ingress` (again on
|
|
7
|
-
each replica, see `_IngressOnReplica`),
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
each replica, see `_IngressOnReplica`), wraps it as a Ray Serve deployment
|
|
8
|
+
with the replica count and Ray resource requests `deploy_model` derived from
|
|
9
|
+
the model's requirements, and binds it with the (family, suffix, run_name)
|
|
10
10
|
identifiers.
|
|
11
11
|
|
|
12
12
|
The serve-app owns everything about traffic: its own routes, request schemas,
|
|
@@ -14,11 +14,9 @@ streaming, and timeouts. cortexgrid does not interpose a request/response
|
|
|
14
14
|
contract - it only schedules the app and hands it the identifiers it needs to
|
|
15
15
|
fetch its own weights via `cortexgrid.load_model`.
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
declare resources in practice; revisit if plain class attributes prove too
|
|
21
|
-
limited.
|
|
17
|
+
The serve-app declares no resources: the hardware a replica needs belongs to
|
|
18
|
+
the model and is stored in the registry (`cortexgrid.ModelRequirements`), and
|
|
19
|
+
the replica count is chosen per `deploy_model`.
|
|
22
20
|
"""
|
|
23
21
|
|
|
24
22
|
from __future__ import annotations
|
|
@@ -72,12 +70,13 @@ def build(args: dict[str, Any]) -> Application:
|
|
|
72
70
|
{"_serve_app": serve_app},
|
|
73
71
|
)
|
|
74
72
|
serve_app = serve.ingress(app)(on_replica)
|
|
75
|
-
num_gpus = getattr(serve_app, "num_gpus", 0)
|
|
76
|
-
num_replicas = getattr(serve_app, "num_replicas", 1)
|
|
77
73
|
return serve.deployment(serve_app).options(
|
|
78
|
-
|
|
74
|
+
# This builder ships in the bundle, frozen at save time, while `args`
|
|
75
|
+
# come from the cortexgrid that deploys it; one older than the bundle
|
|
76
|
+
# sends neither key.
|
|
77
|
+
num_replicas=args.get("num_replicas", 1),
|
|
79
78
|
# Ray 2.32 lowered the default from 100 to 5; keep what serve-apps
|
|
80
79
|
# had on Ray 2.9.
|
|
81
80
|
max_ongoing_requests=_MAX_ONGOING_REQUESTS,
|
|
82
|
-
ray_actor_options=
|
|
81
|
+
ray_actor_options=args.get("ray_actor_options", {}),
|
|
83
82
|
).bind(args["family"], args["suffix"], args["run_name"])
|
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
Caller stays HTTP-only: deploy/undeploy/list talk to the Ray dashboard's
|
|
4
4
|
declarative `/api/serve/applications/` endpoint via [cortexgrid.ray_util],
|
|
5
5
|
never `ray.init`. The deployment class is bundled at `save_model` time, zipped,
|
|
6
|
-
uploaded to MinIO under
|
|
6
|
+
uploaded to MinIO under
|
|
7
|
+
`serve-bundles/<run_name>/<family>__<suffix>/<fingerprint>.zip`, and
|
|
7
8
|
referenced via `runtime_env.working_dir` so Ray workers fetch it from there.
|
|
8
|
-
The bundle URL, class import path,
|
|
9
|
-
on the ModelVersion so `deploy_model` can find them later without
|
|
10
|
-
holding the class object.
|
|
9
|
+
The bundle URL, class import path, pip list, and fingerprint are persisted as
|
|
10
|
+
MLflow tags on the ModelVersion so `deploy_model` can find them later without
|
|
11
|
+
the caller holding the class object. So are the model's `ModelRequirements`,
|
|
12
|
+
which `deploy_model` turns into the replica's Ray resource requests.
|
|
11
13
|
|
|
12
14
|
Naming: the Ray Serve application is named "<family>__<suffix>__<run_name>".
|
|
13
15
|
This relies on family/suffix/run_name not containing the literal "__".
|
|
@@ -26,13 +28,14 @@ import shutil
|
|
|
26
28
|
import tempfile
|
|
27
29
|
import time
|
|
28
30
|
from dataclasses import dataclass, field
|
|
31
|
+
import hashlib
|
|
29
32
|
from pathlib import Path
|
|
30
33
|
from typing import Any
|
|
31
34
|
|
|
32
35
|
from mlflow.tracking import MlflowClient
|
|
33
36
|
from ray.serve.schema import ApplicationStatus
|
|
34
37
|
|
|
35
|
-
from cortexgrid._bundle import bundle, stage, worker_provides
|
|
38
|
+
from cortexgrid._bundle import bundle, digest, stage, worker_provides
|
|
36
39
|
from cortexgrid.infra import get_mlflow_tracking_uri, get_ray_serve_uri
|
|
37
40
|
from cortexgrid.ray_util import (
|
|
38
41
|
get_serve_details,
|
|
@@ -95,17 +98,28 @@ class BundleMetadata:
|
|
|
95
98
|
# pinned third-party requirements the replica pip-installs (the bundle's
|
|
96
99
|
# distributions the Ray image does not already provide)
|
|
97
100
|
pip_requirements: list[str] = field(default_factory=list)
|
|
101
|
+
# ServeBundle.fingerprint of the uploaded bundle; empty for models saved
|
|
102
|
+
# before bundles were fingerprinted
|
|
103
|
+
fingerprint: str = ""
|
|
98
104
|
|
|
99
105
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
upload to MinIO.
|
|
106
|
+
@dataclass
|
|
107
|
+
class ServeBundle:
|
|
108
|
+
"""A serve-app's bundle, resolved locally but not uploaded yet: what
|
|
109
|
+
`build_bundle` finds and `upload_bundle` ships."""
|
|
105
110
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
111
|
+
files: set[Path]
|
|
112
|
+
class_import_path: str
|
|
113
|
+
pip_requirements: list[str]
|
|
114
|
+
# Hash of everything the replica runs: the staged files, the class it
|
|
115
|
+
# imports, and the requirements it installs. Equal fingerprints mean the
|
|
116
|
+
# same code, so an uploaded bundle can be reused.
|
|
117
|
+
fingerprint: str
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def build_bundle(cls: type) -> ServeBundle:
|
|
121
|
+
"""Resolve the serve-app class's code (and the serve entrypoint) into a
|
|
122
|
+
bundle, without uploading it.
|
|
109
123
|
|
|
110
124
|
Raises ValueError for a class wrapped by `ray.serve.ingress`: that wrapper
|
|
111
125
|
is a subclass Ray defines in its own module, and on older Ray (e.g. 2.9) it
|
|
@@ -120,17 +134,51 @@ def bundle_class(
|
|
|
120
134
|
entry_file = Path(inspect.getfile(cls)).resolve()
|
|
121
135
|
serve_entry = Path(__file__).with_name("_serve_entry.py")
|
|
122
136
|
desc = bundle(entry_file).merge(bundle(serve_entry))
|
|
137
|
+
class_import_path = f"{cls.__module__}:{cls.__name__}"
|
|
123
138
|
pip_requirements = desc.pip_requirements(worker_provides())
|
|
139
|
+
fingerprint = hashlib.sha256(
|
|
140
|
+
json.dumps(
|
|
141
|
+
[digest(desc.local_files), class_import_path, pip_requirements]
|
|
142
|
+
).encode()
|
|
143
|
+
).hexdigest()
|
|
144
|
+
return ServeBundle(
|
|
145
|
+
files=desc.local_files,
|
|
146
|
+
class_import_path=class_import_path,
|
|
147
|
+
pip_requirements=pip_requirements,
|
|
148
|
+
fingerprint=fingerprint,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def bundle_class(
|
|
153
|
+
cls: type, family: str, suffix: str, run_name: str
|
|
154
|
+
) -> BundleMetadata:
|
|
155
|
+
"""Bundle the serve-app class's code (and the serve entrypoint), zip it, and
|
|
156
|
+
upload to MinIO: `build_bundle` followed by `upload_bundle`.
|
|
157
|
+
|
|
158
|
+
Returns the metadata `deploy_model` needs later; callers (typically
|
|
159
|
+
`save_model`) persist it on the ModelVersion so the deploy step can run
|
|
160
|
+
without holding the class object."""
|
|
161
|
+
return upload_bundle(build_bundle(cls), family, suffix, run_name)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def upload_bundle(
|
|
165
|
+
serve_bundle: ServeBundle, family: str, suffix: str, run_name: str
|
|
166
|
+
) -> BundleMetadata:
|
|
167
|
+
"""Zip a built bundle and upload it under its fingerprint.
|
|
168
|
+
|
|
169
|
+
The fingerprint is part of the URL because Ray keeps a remote working_dir
|
|
170
|
+
it has downloaded and reuses it for the same URL: new code at an old URL
|
|
171
|
+
would never reach a replica."""
|
|
124
172
|
with tempfile.TemporaryDirectory() as tmp:
|
|
125
173
|
code_root = Path(tmp) / "code"
|
|
126
|
-
stage(
|
|
174
|
+
stage(serve_bundle.files, code_root)
|
|
127
175
|
log.info(
|
|
128
176
|
"Serve bundle for %s/%s/%s: %d files, pip: %s",
|
|
129
177
|
family,
|
|
130
178
|
suffix,
|
|
131
179
|
run_name,
|
|
132
|
-
len(
|
|
133
|
-
pip_requirements,
|
|
180
|
+
len(serve_bundle.files),
|
|
181
|
+
serve_bundle.pip_requirements,
|
|
134
182
|
)
|
|
135
183
|
# Ray unpacks a remote (s3://) working_dir zip by stripping its
|
|
136
184
|
# top-level directory when there is exactly one, so a bundle of a single
|
|
@@ -146,19 +194,54 @@ def bundle_class(
|
|
|
146
194
|
)
|
|
147
195
|
bundle_url = upload(
|
|
148
196
|
archive,
|
|
149
|
-
dest_path=
|
|
197
|
+
dest_path=(
|
|
198
|
+
f"serve-bundles/{run_name}/{family}__{suffix}/"
|
|
199
|
+
f"{serve_bundle.fingerprint}.zip"
|
|
200
|
+
),
|
|
150
201
|
)
|
|
151
202
|
return BundleMetadata(
|
|
152
203
|
bundle_url=bundle_url,
|
|
153
|
-
class_import_path=
|
|
154
|
-
pip_requirements=pip_requirements,
|
|
204
|
+
class_import_path=serve_bundle.class_import_path,
|
|
205
|
+
pip_requirements=serve_bundle.pip_requirements,
|
|
206
|
+
fingerprint=serve_bundle.fingerprint,
|
|
155
207
|
)
|
|
156
208
|
|
|
157
209
|
|
|
210
|
+
# Custom Ray resource each GPU worker advertises: the MiB of memory its GPUs
|
|
211
|
+
# have (see the ray-worker DaemonSet). A replica requests its vram_gb of it in
|
|
212
|
+
# MiB, so Ray places it only on a node with that much VRAM left. MiB because
|
|
213
|
+
# nvidia-smi reports MiB and a GPU's memory is not a whole number of GiB.
|
|
214
|
+
_VRAM_RESOURCE = "vram_mib"
|
|
215
|
+
|
|
216
|
+
_MIB_PER_GIB = 1024
|
|
217
|
+
|
|
218
|
+
_GIB = 1024**3
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _ray_actor_options(requirements: ModelRequirements) -> dict[str, Any]:
|
|
222
|
+
"""Translate ModelRequirements into a replica's Ray actor resource requests.
|
|
223
|
+
Ray places the replica only on a node with that much free and reserves it
|
|
224
|
+
there; a zero requirement requests nothing."""
|
|
225
|
+
options: dict[str, Any] = {"num_gpus": requirements.num_gpus}
|
|
226
|
+
if requirements.ram_gb > 0:
|
|
227
|
+
options["memory"] = int(requirements.ram_gb * _GIB)
|
|
228
|
+
if requirements.vram_gb > 0:
|
|
229
|
+
options["resources"] = {
|
|
230
|
+
_VRAM_RESOURCE: round(requirements.vram_gb * _MIB_PER_GIB)
|
|
231
|
+
}
|
|
232
|
+
return options
|
|
233
|
+
|
|
234
|
+
|
|
158
235
|
def _build_application_spec(
|
|
159
|
-
family: str,
|
|
236
|
+
family: str,
|
|
237
|
+
suffix: str,
|
|
238
|
+
run_name: str,
|
|
239
|
+
meta: BundleMetadata,
|
|
240
|
+
requirements: ModelRequirements,
|
|
241
|
+
num_replicas: int,
|
|
160
242
|
) -> dict[str, Any]:
|
|
161
|
-
"""Assemble a Ray Serve application schema from pre-bundled metadata
|
|
243
|
+
"""Assemble a Ray Serve application schema from pre-bundled metadata and
|
|
244
|
+
the model's requirements."""
|
|
162
245
|
# working_dir carries the serve-app's own source; Ray pip-installs the
|
|
163
246
|
# third-party distributions the image lacks into a per-node cached
|
|
164
247
|
# virtualenv layered on the image. No pip key when there are none, so Ray
|
|
@@ -179,6 +262,8 @@ def _build_application_spec(
|
|
|
179
262
|
"family": family,
|
|
180
263
|
"suffix": suffix,
|
|
181
264
|
"run_name": run_name,
|
|
265
|
+
"num_replicas": num_replicas,
|
|
266
|
+
"ray_actor_options": _ray_actor_options(requirements),
|
|
182
267
|
},
|
|
183
268
|
"runtime_env": runtime_env,
|
|
184
269
|
}
|
|
@@ -189,23 +274,94 @@ def _build_application_spec(
|
|
|
189
274
|
_CLASS_IMPORT_PATH_TAG = "class_import_path"
|
|
190
275
|
_BUNDLE_URL_TAG = "serve_bundle_url"
|
|
191
276
|
_PIP_REQUIREMENTS_TAG = "serve_pip_requirements"
|
|
277
|
+
_BUNDLE_FINGERPRINT_TAG = "serve_bundle_fingerprint"
|
|
192
278
|
|
|
193
279
|
|
|
194
280
|
def metadata_to_tags(meta: BundleMetadata) -> dict[str, str]:
|
|
195
281
|
"""Serialise BundleMetadata to MLflow tags. The inverse of
|
|
196
|
-
`
|
|
282
|
+
`metadata_from_tags`; lives here next to the consumer so the tag schema
|
|
197
283
|
stays in one place."""
|
|
198
284
|
return {
|
|
199
285
|
_CLASS_IMPORT_PATH_TAG: meta.class_import_path,
|
|
200
286
|
_BUNDLE_URL_TAG: meta.bundle_url,
|
|
201
287
|
_PIP_REQUIREMENTS_TAG: json.dumps(meta.pip_requirements),
|
|
288
|
+
_BUNDLE_FINGERPRINT_TAG: meta.fingerprint,
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def metadata_from_tags(tags: dict[str, str]) -> BundleMetadata:
|
|
293
|
+
"""Deserialise BundleMetadata from a ModelVersion's MLflow tags. Raises
|
|
294
|
+
KeyError for a missing bundle URL or class import path."""
|
|
295
|
+
return BundleMetadata(
|
|
296
|
+
bundle_url=tags[_BUNDLE_URL_TAG],
|
|
297
|
+
class_import_path=tags[_CLASS_IMPORT_PATH_TAG],
|
|
298
|
+
# Absent on models saved before dependencies were pip-installed.
|
|
299
|
+
pip_requirements=json.loads(tags.get(_PIP_REQUIREMENTS_TAG, "[]")),
|
|
300
|
+
# Absent on models saved before bundles were fingerprinted.
|
|
301
|
+
fingerprint=tags.get(_BUNDLE_FINGERPRINT_TAG, ""),
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@dataclass
|
|
306
|
+
class ModelRequirements:
|
|
307
|
+
"""Hardware one replica of a model needs to be served, in GiB. Persisted as
|
|
308
|
+
tags on the ModelVersion next to the bundle metadata, so it is read without
|
|
309
|
+
touching the weights or importing the serve-app class.
|
|
310
|
+
|
|
311
|
+
Zero means no requirement: a model with no requirements is served on any
|
|
312
|
+
node, CPU-only included. Models saved before requirements existed carry
|
|
313
|
+
no tags and read as the defaults."""
|
|
314
|
+
|
|
315
|
+
num_gpus: int = 0
|
|
316
|
+
ram_gb: float = 0.0
|
|
317
|
+
# GPU memory across the replica's num_gpus GPUs, so it needs num_gpus >= 1.
|
|
318
|
+
vram_gb: float = 0.0
|
|
319
|
+
|
|
320
|
+
def __post_init__(self) -> None:
|
|
321
|
+
if self.num_gpus < 0 or self.ram_gb < 0 or self.vram_gb < 0:
|
|
322
|
+
raise ValueError(f"Model requirements cannot be negative: {self}")
|
|
323
|
+
if self.vram_gb > 0 and self.num_gpus == 0:
|
|
324
|
+
raise ValueError(
|
|
325
|
+
f"vram_gb={self.vram_gb} needs a GPU; set num_gpus >= 1"
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# MLflow tag keys for the ModelRequirements.
|
|
330
|
+
_NUM_GPUS_TAG = "num_gpus"
|
|
331
|
+
_RAM_GB_TAG = "ram_gb"
|
|
332
|
+
_VRAM_GB_TAG = "vram_gb"
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def has_requirement_tags(tags: dict[str, str]) -> bool:
|
|
336
|
+
"""Whether requirements were ever stored on the ModelVersion."""
|
|
337
|
+
return any(key in tags for key in (_NUM_GPUS_TAG, _RAM_GB_TAG, _VRAM_GB_TAG))
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def requirements_to_tags(requirements: ModelRequirements) -> dict[str, str]:
|
|
341
|
+
"""Serialise ModelRequirements to MLflow tags. The inverse of
|
|
342
|
+
`requirements_from_tags`."""
|
|
343
|
+
return {
|
|
344
|
+
_NUM_GPUS_TAG: str(requirements.num_gpus),
|
|
345
|
+
_RAM_GB_TAG: str(requirements.ram_gb),
|
|
346
|
+
_VRAM_GB_TAG: str(requirements.vram_gb),
|
|
202
347
|
}
|
|
203
348
|
|
|
204
349
|
|
|
205
|
-
def
|
|
350
|
+
def requirements_from_tags(tags: dict[str, str]) -> ModelRequirements:
|
|
351
|
+
"""Deserialise ModelRequirements from a ModelVersion's MLflow tags; a
|
|
352
|
+
missing tag reads as no requirement."""
|
|
353
|
+
return ModelRequirements(
|
|
354
|
+
num_gpus=int(tags.get(_NUM_GPUS_TAG, "0")),
|
|
355
|
+
ram_gb=float(tags.get(_RAM_GB_TAG, "0")),
|
|
356
|
+
vram_gb=float(tags.get(_VRAM_GB_TAG, "0")),
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _load_deploy_metadata(
|
|
206
361
|
family: str, suffix: str, run_name: str
|
|
207
|
-
) -> BundleMetadata:
|
|
208
|
-
"""Read the bundle metadata `save_model` persisted on the
|
|
362
|
+
) -> tuple[BundleMetadata, ModelRequirements]:
|
|
363
|
+
"""Read the bundle metadata and requirements `save_model` persisted on the
|
|
364
|
+
ModelVersion."""
|
|
209
365
|
client = MlflowClient(tracking_uri=get_mlflow_tracking_uri())
|
|
210
366
|
name = f"{family}__{suffix}"
|
|
211
367
|
versions = client.search_model_versions(
|
|
@@ -217,12 +373,7 @@ def _load_bundle_metadata(
|
|
|
217
373
|
)
|
|
218
374
|
tags = versions[0].tags or {}
|
|
219
375
|
try:
|
|
220
|
-
return
|
|
221
|
-
bundle_url=tags[_BUNDLE_URL_TAG],
|
|
222
|
-
class_import_path=tags[_CLASS_IMPORT_PATH_TAG],
|
|
223
|
-
# Absent on models saved before dependencies were pip-installed.
|
|
224
|
-
pip_requirements=json.loads(tags.get(_PIP_REQUIREMENTS_TAG, "[]")),
|
|
225
|
-
)
|
|
376
|
+
return metadata_from_tags(tags), requirements_from_tags(tags)
|
|
226
377
|
except KeyError as exc:
|
|
227
378
|
raise ValueError(
|
|
228
379
|
f"Saved model {family}/{suffix}/{run_name} is missing the deployment "
|
|
@@ -338,6 +489,7 @@ def deploy_model(
|
|
|
338
489
|
family: str,
|
|
339
490
|
suffix: str,
|
|
340
491
|
run_name: str,
|
|
492
|
+
num_replicas: int = 1,
|
|
341
493
|
wait: bool = False,
|
|
342
494
|
timeout: float | None = 300.0,
|
|
343
495
|
) -> Deployment:
|
|
@@ -348,6 +500,8 @@ def deploy_model(
|
|
|
348
500
|
|
|
349
501
|
The serve-app class is pulled from the MLflow ModelVersion tags `save_model`
|
|
350
502
|
wrote at save time; the caller does not need to hold the class object.
|
|
503
|
+
Each of the `num_replicas` replicas requests the model's `ModelRequirements`
|
|
504
|
+
from Ray, so it is placed only on a node that has them free.
|
|
351
505
|
|
|
352
506
|
A DEPLOY_FAILED app left by an earlier attempt is undeployed first, and it,
|
|
353
507
|
or an app still DELETING, is waited out before the new spec is PUT, so the
|
|
@@ -361,8 +515,10 @@ def deploy_model(
|
|
|
361
515
|
stuck in DEPLOYING) will hang forever.
|
|
362
516
|
"""
|
|
363
517
|
deadline = _deadline(timeout)
|
|
364
|
-
meta =
|
|
365
|
-
spec = _build_application_spec(
|
|
518
|
+
meta, requirements = _load_deploy_metadata(family, suffix, run_name)
|
|
519
|
+
spec = _build_application_spec(
|
|
520
|
+
family, suffix, run_name, meta, requirements, num_replicas
|
|
521
|
+
)
|
|
366
522
|
_clear_failed_application(family, suffix, run_name, timeout, deadline)
|
|
367
523
|
existing = [a for a in _current_application_specs() if a["name"] != spec["name"]]
|
|
368
524
|
# The controller registers the app, sets it DEPLOYING and stamps
|
|
@@ -8,11 +8,13 @@ Mapping cortexgrid taxonomy <-> MLflow Registry:
|
|
|
8
8
|
"s3://<bucket>/models/<run_name>/<family>/<suffix>/weights/"
|
|
9
9
|
run linkage -> ModelVersion.run_id (built-in MLflow field; unset
|
|
10
10
|
for imported models)
|
|
11
|
+
requirements -> ModelVersion.tags["num_gpus"], ["ram_gb"], ["vram_gb"]
|
|
11
12
|
|
|
12
13
|
Two ways in: `save_model` registers a fresh copy under the calling run's
|
|
13
14
|
run_name every time it runs (fine-tuned output); `import_model` registers a
|
|
14
|
-
model produced elsewhere once, under the fixed run_name IMPORTED, and
|
|
15
|
-
|
|
15
|
+
model produced elsewhere once, under the fixed run_name IMPORTED, and after
|
|
16
|
+
that only re-bundles the serve-app when its code changed. Both write the same
|
|
17
|
+
layout, so every
|
|
16
18
|
(family, suffix, run_name) consumer - load_model, deploy_model - handles both.
|
|
17
19
|
|
|
18
20
|
storage.py is pure: it takes run_id/run_name as explicit args and never reads
|
|
@@ -35,8 +37,15 @@ from mlflow.tracking import MlflowClient
|
|
|
35
37
|
from cortexgrid import s3_util
|
|
36
38
|
from cortexgrid.infra import get_mlflow_tracking_uri, get_s3_bucket
|
|
37
39
|
from cortexgrid.model_serving import (
|
|
40
|
+
ModelRequirements,
|
|
41
|
+
build_bundle,
|
|
38
42
|
bundle_class,
|
|
43
|
+
has_requirement_tags,
|
|
44
|
+
metadata_from_tags,
|
|
39
45
|
metadata_to_tags,
|
|
46
|
+
requirements_from_tags,
|
|
47
|
+
requirements_to_tags,
|
|
48
|
+
upload_bundle,
|
|
40
49
|
)
|
|
41
50
|
|
|
42
51
|
|
|
@@ -77,6 +86,8 @@ class SavedModel:
|
|
|
77
86
|
# _UPLOAD_DEADLINE (writer presumed dead). Versions written before this tag
|
|
78
87
|
# existed report "ready".
|
|
79
88
|
phase: str
|
|
89
|
+
# Hardware one replica needs; defaults for versions stored without it.
|
|
90
|
+
requirements: ModelRequirements
|
|
80
91
|
|
|
81
92
|
|
|
82
93
|
def _phase_for(version: Any) -> str:
|
|
@@ -107,6 +118,7 @@ def _to_saved_model(version: Any) -> SavedModel:
|
|
|
107
118
|
data_blob_path=version.source,
|
|
108
119
|
size_bytes=int(version.tags.get("size_bytes", "0")),
|
|
109
120
|
phase=_phase_for(version),
|
|
121
|
+
requirements=requirements_from_tags(version.tags),
|
|
110
122
|
)
|
|
111
123
|
|
|
112
124
|
|
|
@@ -146,6 +158,7 @@ def save_model(
|
|
|
146
158
|
family: str,
|
|
147
159
|
run_id: str,
|
|
148
160
|
run_name: str,
|
|
161
|
+
requirements: ModelRequirements | None = None,
|
|
149
162
|
) -> SavedModel:
|
|
150
163
|
"""Upload a weights directory to S3 and register a new MLflow ModelVersion
|
|
151
164
|
paired with the serve-app that fronts it.
|
|
@@ -164,8 +177,13 @@ def save_model(
|
|
|
164
177
|
weights.
|
|
165
178
|
Its code is bundled and its import path, bundle URL, and pip list are
|
|
166
179
|
stored as tags on the ModelVersion so `deploy_model` can bind it later
|
|
167
|
-
without the caller holding the class object.
|
|
168
|
-
|
|
180
|
+
without the caller holding the class object.
|
|
181
|
+
|
|
182
|
+
`requirements` is the hardware one replica needs; None stores none, which
|
|
183
|
+
reads as no requirement. Change it later with `set_model_requirements`."""
|
|
184
|
+
return _upload_model(
|
|
185
|
+
weights_dir, serve_app, suffix, family, run_id, run_name, requirements
|
|
186
|
+
)
|
|
169
187
|
|
|
170
188
|
|
|
171
189
|
def import_model(
|
|
@@ -173,6 +191,7 @@ def import_model(
|
|
|
173
191
|
serve_app: type,
|
|
174
192
|
family: str,
|
|
175
193
|
suffix: str,
|
|
194
|
+
requirements: ModelRequirements | None = None,
|
|
176
195
|
) -> SavedModel:
|
|
177
196
|
"""Register a model produced elsewhere (e.g. a pretrained base model) under
|
|
178
197
|
the fixed key (family, suffix, IMPORTED), once.
|
|
@@ -184,8 +203,12 @@ def import_model(
|
|
|
184
203
|
while it downloads.
|
|
185
204
|
|
|
186
205
|
If a version is already registered under the key:
|
|
187
|
-
- "ready":
|
|
188
|
-
|
|
206
|
+
- "ready": returns it without calling `source`. If `serve_app`'s code no
|
|
207
|
+
longer matches the stored bundle, it is re-bundled first and the
|
|
208
|
+
weights are kept (see `_refresh_bundle`). `requirements` are stored
|
|
209
|
+
only if the version has none yet, so values changed since with
|
|
210
|
+
`set_model_requirements` are kept. To replace the weights,
|
|
211
|
+
`delete_model` it first.
|
|
189
212
|
- "uploading": raises RuntimeError - another process is importing it.
|
|
190
213
|
- "upload_failed" / "broken": deleted and imported again.
|
|
191
214
|
|
|
@@ -195,6 +218,11 @@ def import_model(
|
|
|
195
218
|
existing = model_registry_status(family, suffix, IMPORTED)
|
|
196
219
|
if existing is not None:
|
|
197
220
|
if existing.phase == _PHASE_READY:
|
|
221
|
+
_refresh_bundle(serve_app, family, suffix)
|
|
222
|
+
if requirements is not None:
|
|
223
|
+
existing.requirements = _set_missing_requirements(
|
|
224
|
+
family, suffix, requirements
|
|
225
|
+
)
|
|
198
226
|
return existing
|
|
199
227
|
if existing.phase == _PHASE_UPLOADING:
|
|
200
228
|
raise RuntimeError(
|
|
@@ -202,7 +230,47 @@ def import_model(
|
|
|
202
230
|
"another process"
|
|
203
231
|
)
|
|
204
232
|
delete_model(family, suffix, IMPORTED)
|
|
205
|
-
return _upload_model(
|
|
233
|
+
return _upload_model(
|
|
234
|
+
source, serve_app, suffix, family, None, IMPORTED, requirements
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _refresh_bundle(serve_app: type, family: str, suffix: str) -> None:
|
|
239
|
+
"""Re-bundle an imported model's serve-app when its fingerprint differs
|
|
240
|
+
from the bundle stored on the version, leaving the weights in place.
|
|
241
|
+
|
|
242
|
+
The new bundle is uploaded next to the old one and the version's tags are
|
|
243
|
+
pointed at it, so the next `deploy_model` runs the new code. An app that is
|
|
244
|
+
already running keeps the code it started with until it is deployed again;
|
|
245
|
+
the old bundle stays in storage so that app can still restart."""
|
|
246
|
+
name = f"{family}__{suffix}"
|
|
247
|
+
client = MlflowClient(tracking_uri=get_mlflow_tracking_uri())
|
|
248
|
+
version = client.search_model_versions(
|
|
249
|
+
f"name='{name}' and tags.run_name='{IMPORTED}'"
|
|
250
|
+
)[0]
|
|
251
|
+
serve_bundle = build_bundle(serve_app)
|
|
252
|
+
if metadata_from_tags(version.tags).fingerprint == serve_bundle.fingerprint:
|
|
253
|
+
return
|
|
254
|
+
meta = upload_bundle(serve_bundle, family, suffix, IMPORTED)
|
|
255
|
+
for key, value in metadata_to_tags(meta).items():
|
|
256
|
+
client.set_model_version_tag(name, version.version, key, value)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _set_missing_requirements(
|
|
260
|
+
family: str, suffix: str, requirements: ModelRequirements
|
|
261
|
+
) -> ModelRequirements:
|
|
262
|
+
"""Store `requirements` on an imported model whose version has none yet.
|
|
263
|
+
Returns the requirements the version holds afterwards."""
|
|
264
|
+
name = f"{family}__{suffix}"
|
|
265
|
+
client = MlflowClient(tracking_uri=get_mlflow_tracking_uri())
|
|
266
|
+
version = client.search_model_versions(
|
|
267
|
+
f"name='{name}' and tags.run_name='{IMPORTED}'"
|
|
268
|
+
)[0]
|
|
269
|
+
if has_requirement_tags(version.tags):
|
|
270
|
+
return requirements_from_tags(version.tags)
|
|
271
|
+
for key, value in requirements_to_tags(requirements).items():
|
|
272
|
+
client.set_model_version_tag(name, version.version, key, value)
|
|
273
|
+
return requirements
|
|
206
274
|
|
|
207
275
|
|
|
208
276
|
def _upload_model(
|
|
@@ -212,6 +280,7 @@ def _upload_model(
|
|
|
212
280
|
family: str,
|
|
213
281
|
run_id: str | None,
|
|
214
282
|
run_name: str,
|
|
283
|
+
requirements: ModelRequirements | None,
|
|
215
284
|
) -> SavedModel:
|
|
216
285
|
"""Register a ModelVersion in "uploading", resolve `weights` to a directory
|
|
217
286
|
(calling it when it is a callable), upload the weights and the serve-app
|
|
@@ -227,7 +296,8 @@ def _upload_model(
|
|
|
227
296
|
# fetched and uploaded, and a concurrent `import_model` sees the import in
|
|
228
297
|
# flight for the whole download instead of starting one of its own. The
|
|
229
298
|
# size is stamped once the directory exists; the bundle tags and the flip
|
|
230
|
-
# to "ready" happen only after the upload lands.
|
|
299
|
+
# to "ready" happen only after the upload lands. No requirements leaves
|
|
300
|
+
# their tags unset, so a later `import_model` can still store them.
|
|
231
301
|
version = client.create_model_version(
|
|
232
302
|
name=name,
|
|
233
303
|
source=source,
|
|
@@ -237,6 +307,7 @@ def _upload_model(
|
|
|
237
307
|
"suffix": suffix,
|
|
238
308
|
"run_name": run_name,
|
|
239
309
|
_LIFECYCLE_TAG: _PHASE_UPLOADING,
|
|
310
|
+
**(requirements_to_tags(requirements) if requirements is not None else {}),
|
|
240
311
|
},
|
|
241
312
|
)
|
|
242
313
|
try:
|
|
@@ -305,6 +376,23 @@ def model_registry_status(
|
|
|
305
376
|
return _to_saved_model(versions[0]) if versions else None
|
|
306
377
|
|
|
307
378
|
|
|
379
|
+
def set_model_requirements(
|
|
380
|
+
family: str, suffix: str, run_name: str, requirements: ModelRequirements
|
|
381
|
+
) -> None:
|
|
382
|
+
"""Replace the hardware requirements stored on a model. Takes effect on
|
|
383
|
+
its next `deploy_model`; a replica already running keeps its placement.
|
|
384
|
+
Raises ValueError if the model was never registered."""
|
|
385
|
+
client = MlflowClient(tracking_uri=get_mlflow_tracking_uri())
|
|
386
|
+
name = f"{family}__{suffix}"
|
|
387
|
+
versions = client.search_model_versions(
|
|
388
|
+
f"name='{name}' and tags.run_name='{run_name}'"
|
|
389
|
+
)
|
|
390
|
+
if not versions:
|
|
391
|
+
raise ValueError(f"No model {family}/{suffix}/{run_name}")
|
|
392
|
+
for key, value in requirements_to_tags(requirements).items():
|
|
393
|
+
client.set_model_version_tag(name, versions[0].version, key, value)
|
|
394
|
+
|
|
395
|
+
|
|
308
396
|
def delete_model(family: str, suffix: str, run_name: str) -> None:
|
|
309
397
|
"""Delete the ModelVersion in MLflow, its weights blob, and its serve bundle."""
|
|
310
398
|
client = MlflowClient(tracking_uri=get_mlflow_tracking_uri())
|
|
@@ -137,20 +137,24 @@ app = FastAPI()
|
|
|
137
137
|
|
|
138
138
|
@serve.ingress(app)
|
|
139
139
|
class MyServeApp:
|
|
140
|
-
num_gpus = 1
|
|
141
|
-
|
|
142
140
|
def __init__(self, family: str, suffix: str, run_name: str) -> None:
|
|
143
141
|
self._weights_dir = cortexgrid.load_model(family, suffix, run_name)
|
|
144
142
|
|
|
145
143
|
@app.post("/complete")
|
|
146
144
|
async def complete(self, body: dict): ...
|
|
147
145
|
|
|
148
|
-
saved = cortexgrid.save_model(
|
|
146
|
+
saved = cortexgrid.save_model(
|
|
147
|
+
weights_dir, MyServeApp, family="qwen", suffix="instruct",
|
|
148
|
+
# What one replica needs; the model is deployed only on a host that has it.
|
|
149
|
+
requirements=cortexgrid.ModelRequirements(num_gpus=1, ram_gb=8, vram_gb=16),
|
|
150
|
+
)
|
|
149
151
|
deployed = cortexgrid.deploy_model("qwen", "instruct", saved.run_name, wait=True)
|
|
150
152
|
print(deployed.url)
|
|
151
153
|
```
|
|
152
154
|
|
|
153
|
-
|
|
155
|
+
The requirements are part of the model, not of the serve-app class: GPUs, RAM and VRAM (GiB, 0 meaning no requirement) are stored with it and matched against what the cluster's hosts have free. Correct them later with `cortexgrid.set_model_requirements(family, suffix, run_name, requirements)` or on the model card in the dashboard; `cortexgrid.deploy_model(..., num_replicas=2)` chooses how many copies to run.
|
|
156
|
+
|
|
157
|
+
`save_model` saves a new copy under every run - meant for weights the run produced (e.g. a fine-tune). For a model produced elsewhere (e.g. a pretrained base model), `cortexgrid.import_model(source, MyServeApp, family, suffix)` uploads it once under `run_name=cortexgrid.IMPORTED` and on later runs only re-bundles `MyServeApp` if its code changed; deploy it with `deploy_model(family, suffix, cortexgrid.IMPORTED)`.
|
|
154
158
|
|
|
155
159
|
`save_model` is synchronous (registry lifecycle: `uploading` -> `ready`); `deploy_model` schedules the serving lifecycle (`deploying` -> `running`). With `wait=True` a failed deploy raises `cortexgrid.ModelDeployFailed`; `cortexgrid.wait_for_model_serving(family, suffix, run_name, timeout=...)` waits on a deploy started elsewhere, and re-deploying a failed model retries it from scratch. See [model-serving.md](https://github.com/robodatalab/cortexgrid/blob/main/docs/cortexgrid/model-serving.md) for both lifecycles end to end - upload/deploy/undeploy/delete, status queries (`model_registry_status`, `model_serving_status`), and error handling.
|
|
156
160
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|