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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cortexgrid
3
- Version: 0.2.95
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(weights_dir, MyServeApp, family="qwen", suffix="instruct")
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
- `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 is a no-op on later runs; deploy it with `deploy_model(family, suffix, cortexgrid.IMPORTED)`.
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, serve_app: type, family: str, suffix: str
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 / file.relative_to(_sys_path_root(file))
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`), reads its
8
- `num_gpus`/`num_replicas` class attributes for actor placement, wraps it as a
9
- Ray Serve deployment, and binds it with the (family, suffix, run_name)
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
- Design note: resource needs (`num_gpus`/`num_replicas`) are read from plain
18
- class attributes rather than a cortexgrid decorator or base class. This is a
19
- deliberate, provisional choice - kept minimal until we see how serve-apps
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
- num_replicas=num_replicas,
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={"num_gpus": num_gpus},
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 `serve-bundles/<run_name>/<family>__<suffix>.zip`, and
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, and pip list are persisted as MLflow tags
9
- on the ModelVersion so `deploy_model` can find them later without the caller
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
- def bundle_class(
101
- cls: type, family: str, suffix: str, run_name: str
102
- ) -> BundleMetadata:
103
- """Bundle the serve-app class's code (and the serve entrypoint), zip it, and
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
- Returns the metadata `deploy_model` needs later; callers (typically
107
- `save_model`) persist it on the ModelVersion so the deploy step can run
108
- without holding the class object.
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(desc.local_files, code_root)
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(desc.local_files),
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=f"serve-bundles/{run_name}/{family}__{suffix}.zip",
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=f"{cls.__module__}:{cls.__name__}",
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, suffix: str, run_name: str, meta: BundleMetadata
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
- `_load_bundle_metadata`; lives here next to the consumer so the tag schema
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 _load_bundle_metadata(
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 ModelVersion."""
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 BundleMetadata(
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 = _load_bundle_metadata(family, suffix, run_name)
365
- spec = _build_application_spec(family, suffix, run_name, meta)
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 is a
15
- no-op after that. Both write the same layout, so every
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
- return _upload_model(weights_dir, serve_app, suffix, family, run_id, run_name)
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": no-op, returns it. `source` and `serve_app` are ignored; to
188
- replace the weights or the serve-app, `delete_model` it first.
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(source, serve_app, suffix, family, None, IMPORTED)
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(weights_dir, MyServeApp, family="qwen", suffix="instruct")
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
- `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 is a no-op on later runs; deploy it with `deploy_model(family, suffix, cortexgrid.IMPORTED)`.
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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cortexgrid"
3
- version = "0.2.95"
3
+ version = "0.2.97"
4
4
  description = "Connect your ML code to the RoboLab compute cluster — Ray, MLflow, and S3"
5
5
  readme = "docs/cortexgrid/README.md"
6
6
  license = "Apache-2.0"
File without changes
File without changes