cortexgrid 0.3.14__tar.gz → 0.3.16__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.3.14 → cortexgrid-0.3.16}/PKG-INFO +10 -3
- {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/__init__.py +19 -2
- {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/_model_scheduler.py +105 -25
- {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/_serve_entry.py +3 -1
- {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/model_serving/__init__.py +5 -2
- {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/model_serving/application_spec.py +18 -10
- {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/model_serving/deployment_key.py +21 -4
- {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/model_serving/lifecycle.py +130 -16
- {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/model_serving/placement.py +3 -1
- {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/model_serving/registry_tags.py +23 -0
- {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/model_serving/serve_bundle.py +8 -1
- {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/model_serving/status.py +7 -48
- {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/model_storage.py +35 -6
- {cortexgrid-0.3.14 → cortexgrid-0.3.16}/docs/cortexgrid/README.md +9 -2
- {cortexgrid-0.3.14 → cortexgrid-0.3.16}/pyproject.toml +1 -1
- {cortexgrid-0.3.14 → cortexgrid-0.3.16}/.gitignore +0 -0
- {cortexgrid-0.3.14 → cortexgrid-0.3.16}/LICENSE +0 -0
- {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/_bundle.py +0 -0
- {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/_ray_job_driver.py +0 -0
- {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/checkpoint.py +0 -0
- {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/experiment.py +0 -0
- {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/infra.py +0 -0
- {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/jobs.py +0 -0
- {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/mlflow_util.py +0 -0
- {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/model_serving/deployment_records.py +0 -0
- {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/py.typed +0 -0
- {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/ray_util.py +0 -0
- {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/s3_util.py +0 -0
- {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/secrets.py +0 -0
- {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/serve.py +0 -0
- {cortexgrid-0.3.14 → cortexgrid-0.3.16}/cortexgrid/state.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: cortexgrid
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.16
|
|
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
|
|
@@ -213,8 +213,15 @@ from fastapi import FastAPI
|
|
|
213
213
|
|
|
214
214
|
app = FastAPI()
|
|
215
215
|
|
|
216
|
+
class MyClient(cortexgrid.DeploymentClient):
|
|
217
|
+
def complete(self, prompt: str) -> str: ...
|
|
218
|
+
|
|
216
219
|
@serve.ingress(app)
|
|
217
220
|
class MyServeApp:
|
|
221
|
+
@classmethod
|
|
222
|
+
def client(cls, deployment: cortexgrid.Deployment[MyClient]) -> MyClient:
|
|
223
|
+
return MyClient(key=deployment.key, url=deployment.url)
|
|
224
|
+
|
|
218
225
|
def __init__(self, deployment: cortexgrid.DeploymentKey) -> None:
|
|
219
226
|
self._weights_dir = cortexgrid.load_model(
|
|
220
227
|
deployment.family, deployment.suffix, deployment.run_name
|
|
@@ -228,8 +235,8 @@ saved = cortexgrid.save_model(
|
|
|
228
235
|
# What one replica needs; the model is deployed only on a host that has it.
|
|
229
236
|
requirements=cortexgrid.ModelRequirements(num_gpus=1, ram_gb=8, vram_gb=16),
|
|
230
237
|
)
|
|
231
|
-
deployed = cortexgrid.deploy_model("qwen", "instruct", saved.run_name
|
|
232
|
-
|
|
238
|
+
deployed = cortexgrid.deploy_model("qwen", "instruct", saved.run_name)
|
|
239
|
+
model = deployed.client() # MyServeApp's client, once the app serves
|
|
233
240
|
```
|
|
234
241
|
|
|
235
242
|
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. Among the hosts that fit, the model goes to the **smallest GPU** that does, so a 4 GiB model does not occupy a 128 GiB card a bigger one needs; it moves up only once the smaller cards are full. `num_gpus` may be a fraction (`0.25`) to share one card between models, in which case `vram_gb` is what keeps them from overcommitting it. 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.
|
|
@@ -102,6 +102,7 @@ from cortexgrid.model_storage import register_model as _register_model_storage
|
|
|
102
102
|
from cortexgrid.model_storage import save_model as _save_model_storage
|
|
103
103
|
from cortexgrid.model_serving import (
|
|
104
104
|
Deployment,
|
|
105
|
+
DeploymentClient,
|
|
105
106
|
DeploymentConfig,
|
|
106
107
|
DeploymentKey,
|
|
107
108
|
ModelDeployFailed,
|
|
@@ -111,6 +112,7 @@ from cortexgrid.model_serving import (
|
|
|
111
112
|
list_deployed_models,
|
|
112
113
|
model_serving_status,
|
|
113
114
|
redeploy_model,
|
|
115
|
+
required_models,
|
|
114
116
|
undeploy_model,
|
|
115
117
|
wait_for_model_serving,
|
|
116
118
|
)
|
|
@@ -170,6 +172,7 @@ def save_model(
|
|
|
170
172
|
run produced (e.g. a fine-tune). For a model produced elsewhere that should
|
|
171
173
|
be uploaded once and reused across runs, use `import_model`."""
|
|
172
174
|
experiment = Experiment.get_instance()
|
|
175
|
+
_register_required_models(requirements)
|
|
173
176
|
return _save_model_storage(
|
|
174
177
|
weights_dir,
|
|
175
178
|
serve_app,
|
|
@@ -198,6 +201,7 @@ def import_model(
|
|
|
198
201
|
`created_at` under (family, suffix), whether this call uploaded the model
|
|
199
202
|
or reused it."""
|
|
200
203
|
experiment = Experiment.get_instance()
|
|
204
|
+
_register_required_models(requirements)
|
|
201
205
|
model = _import_model_storage(
|
|
202
206
|
source, serve_app, family, suffix, requirements, config
|
|
203
207
|
)
|
|
@@ -221,6 +225,7 @@ def register_model(
|
|
|
221
225
|
(see `cortexgrid.model_storage.register_model`). The model belongs to no
|
|
222
226
|
run, so the run keeps the link the same way."""
|
|
223
227
|
experiment = Experiment.get_instance()
|
|
228
|
+
_register_required_models(requirements)
|
|
224
229
|
model = _register_model_storage(
|
|
225
230
|
serve_app, family, suffix, requirements, config
|
|
226
231
|
)
|
|
@@ -235,8 +240,8 @@ def deploy_model(
|
|
|
235
240
|
num_replicas: int = 1,
|
|
236
241
|
wait: bool = False,
|
|
237
242
|
timeout: float | None = 300.0,
|
|
238
|
-
config:
|
|
239
|
-
) -> Deployment:
|
|
243
|
+
config: dict[str, str] | None = None,
|
|
244
|
+
) -> Deployment[Any]:
|
|
240
245
|
experiment = active_experiment()
|
|
241
246
|
return _deploy_model_serving(
|
|
242
247
|
family,
|
|
@@ -250,6 +255,16 @@ def deploy_model(
|
|
|
250
255
|
)
|
|
251
256
|
|
|
252
257
|
|
|
258
|
+
def _register_required_models(requirements: ModelRequirements | None) -> None:
|
|
259
|
+
for model in [] if requirements is None else requirements.models:
|
|
260
|
+
if model.serve_app is None:
|
|
261
|
+
continue
|
|
262
|
+
if model.source is None:
|
|
263
|
+
register_model(model.serve_app, model.family, model.suffix)
|
|
264
|
+
else:
|
|
265
|
+
import_model(model.source, model.serve_app, model.family, model.suffix)
|
|
266
|
+
|
|
267
|
+
|
|
253
268
|
def _record_imported_model(
|
|
254
269
|
run_id: str, family: str, suffix: str, model: SavedModel
|
|
255
270
|
) -> None:
|
|
@@ -335,6 +350,7 @@ __all__ = [
|
|
|
335
350
|
"delete_model",
|
|
336
351
|
# Model serving
|
|
337
352
|
"Deployment",
|
|
353
|
+
"DeploymentClient",
|
|
338
354
|
"DeploymentConfig",
|
|
339
355
|
"DeploymentKey",
|
|
340
356
|
"ModelDeployFailed",
|
|
@@ -342,6 +358,7 @@ __all__ = [
|
|
|
342
358
|
"ServingStatus",
|
|
343
359
|
"deploy_model",
|
|
344
360
|
"redeploy_model",
|
|
361
|
+
"required_models",
|
|
345
362
|
"wait_for_model_serving",
|
|
346
363
|
"model_serving_status",
|
|
347
364
|
"undeploy_model",
|
|
@@ -16,9 +16,10 @@ log = logging.getLogger(__name__)
|
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
_RayResources = dict[str, float]
|
|
19
|
+
_ModelGraph = dict[str, set[str]]
|
|
19
20
|
|
|
20
21
|
|
|
21
|
-
_SCHEDULER_PROTOCOL_VERSION =
|
|
22
|
+
_SCHEDULER_PROTOCOL_VERSION = 3
|
|
22
23
|
_SCHEDULER_ACTOR_NAME = f"cortexgrid-model-scheduler-v{_SCHEDULER_PROTOCOL_VERSION}"
|
|
23
24
|
_SCHEDULER_ACTOR_NAMESPACE = "cortexgrid"
|
|
24
25
|
|
|
@@ -33,7 +34,7 @@ _STATE_API_RESULT_LIMIT = 10_000
|
|
|
33
34
|
_RESOURCE_FLOAT_TOLERANCE = 1e-6
|
|
34
35
|
|
|
35
36
|
|
|
36
|
-
def model_autoscaling_config(max_replicas: int) -> dict[str, Any]:
|
|
37
|
+
def model_autoscaling_config(max_replicas: int, required_apps: list[str]) -> dict[str, Any]:
|
|
37
38
|
return {
|
|
38
39
|
"min_replicas": 0,
|
|
39
40
|
"initial_replicas": max_replicas,
|
|
@@ -48,12 +49,14 @@ def model_autoscaling_config(max_replicas: int) -> dict[str, Any]:
|
|
|
48
49
|
f"{ModelAutoscalingPolicy.__module__}:"
|
|
49
50
|
f"{ModelAutoscalingPolicy.__qualname__}"
|
|
50
51
|
),
|
|
52
|
+
"policy_kwargs": {"required_apps": required_apps},
|
|
51
53
|
},
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
|
|
55
57
|
class ModelAutoscalingPolicy:
|
|
56
|
-
def __init__(self) -> None:
|
|
58
|
+
def __init__(self, required_apps: list[str]) -> None:
|
|
59
|
+
self._required_apps = required_apps
|
|
57
60
|
self._scheduler: ActorProxy[_ModelScheduler] | None = None
|
|
58
61
|
self._pending_pause_answer: Future[bool] | None = None
|
|
59
62
|
self._pause_requested_by_scheduler = False
|
|
@@ -63,7 +66,9 @@ class ModelAutoscalingPolicy:
|
|
|
63
66
|
if not self._awaiting_pause_answer():
|
|
64
67
|
self._pause_requested_by_scheduler = self._collect_pause_answer()
|
|
65
68
|
self._send_activity_report(
|
|
66
|
-
context.deployment_id.to_replica_actor_class_name(),
|
|
69
|
+
context.deployment_id.to_replica_actor_class_name(),
|
|
70
|
+
context.deployment_id.app_name,
|
|
71
|
+
has_requests,
|
|
67
72
|
)
|
|
68
73
|
return self._replica_count(context, has_requests), context.policy_state
|
|
69
74
|
|
|
@@ -90,12 +95,14 @@ class ModelAutoscalingPolicy:
|
|
|
90
95
|
self._scheduler = None
|
|
91
96
|
return False
|
|
92
97
|
|
|
93
|
-
def _send_activity_report(
|
|
98
|
+
def _send_activity_report(
|
|
99
|
+
self, replica_class_name: str, app_name: str, has_requests: bool
|
|
100
|
+
) -> None:
|
|
94
101
|
if self._scheduler is None:
|
|
95
102
|
self._scheduler = _get_or_create_scheduler_actor()
|
|
96
103
|
self._pending_pause_answer = (
|
|
97
104
|
self._scheduler.report_activity_and_check_pause.remote(
|
|
98
|
-
replica_class_name, has_requests
|
|
105
|
+
replica_class_name, app_name, self._required_apps, has_requests
|
|
99
106
|
).future()
|
|
100
107
|
)
|
|
101
108
|
|
|
@@ -117,6 +124,8 @@ def _get_or_create_scheduler_actor() -> ActorProxy[_ModelScheduler]:
|
|
|
117
124
|
|
|
118
125
|
@dataclass
|
|
119
126
|
class _ScheduledModel:
|
|
127
|
+
app_name: str = ""
|
|
128
|
+
required_apps: list[str] = field(default_factory=list)
|
|
120
129
|
has_requests: bool = False
|
|
121
130
|
last_request_at: float = 0.0
|
|
122
131
|
last_report_at: float = 0.0
|
|
@@ -131,6 +140,7 @@ class _NodeOccupancy:
|
|
|
131
140
|
@dataclass
|
|
132
141
|
class _ReplicaWaitingForRoom:
|
|
133
142
|
actor_id: str
|
|
143
|
+
replica_class_name: str
|
|
134
144
|
required_resources: _RayResources
|
|
135
145
|
|
|
136
146
|
|
|
@@ -149,16 +159,27 @@ class _ModelScheduler:
|
|
|
149
159
|
|
|
150
160
|
@ray.method
|
|
151
161
|
def report_activity_and_check_pause(
|
|
152
|
-
self,
|
|
162
|
+
self,
|
|
163
|
+
replica_class_name: str,
|
|
164
|
+
app_name: str,
|
|
165
|
+
required_apps: list[str],
|
|
166
|
+
has_requests: bool,
|
|
153
167
|
) -> bool:
|
|
154
168
|
return self._record_activity_and_check_pause(
|
|
155
|
-
replica_class_name, has_requests, time.monotonic()
|
|
169
|
+
replica_class_name, app_name, required_apps, has_requests, time.monotonic()
|
|
156
170
|
)
|
|
157
171
|
|
|
158
172
|
def _record_activity_and_check_pause(
|
|
159
|
-
self,
|
|
173
|
+
self,
|
|
174
|
+
replica_class_name: str,
|
|
175
|
+
app_name: str,
|
|
176
|
+
required_apps: list[str],
|
|
177
|
+
has_requests: bool,
|
|
178
|
+
now: float,
|
|
160
179
|
) -> bool:
|
|
161
|
-
self._record_activity(
|
|
180
|
+
self._record_activity(
|
|
181
|
+
replica_class_name, app_name, required_apps, has_requests, now
|
|
182
|
+
)
|
|
162
183
|
if now - self._last_pause_decision_at >= _PAUSE_DECISION_INTERVAL_S:
|
|
163
184
|
self._last_pause_decision_at = now
|
|
164
185
|
self._forget_models_silent_since(now - _FORGET_MODELS_SILENT_FOR_S)
|
|
@@ -166,9 +187,16 @@ class _ModelScheduler:
|
|
|
166
187
|
return replica_class_name in self._models_to_pause
|
|
167
188
|
|
|
168
189
|
def _record_activity(
|
|
169
|
-
self,
|
|
190
|
+
self,
|
|
191
|
+
replica_class_name: str,
|
|
192
|
+
app_name: str,
|
|
193
|
+
required_apps: list[str],
|
|
194
|
+
has_requests: bool,
|
|
195
|
+
now: float,
|
|
170
196
|
) -> None:
|
|
171
197
|
model = self._models.setdefault(replica_class_name, _ScheduledModel())
|
|
198
|
+
model.app_name = app_name
|
|
199
|
+
model.required_apps = required_apps
|
|
172
200
|
if has_requests:
|
|
173
201
|
model.last_request_at = now
|
|
174
202
|
model.has_requests = has_requests
|
|
@@ -194,25 +222,56 @@ class _ModelScheduler:
|
|
|
194
222
|
occupancy.replicas_waiting_for_room,
|
|
195
223
|
key=lambda replica: self._first_seen_waiting_at[replica.actor_id],
|
|
196
224
|
)
|
|
225
|
+
running = {
|
|
226
|
+
name for node in occupancy.nodes for name in node.resources_held_by_model
|
|
227
|
+
}
|
|
228
|
+
required = self._models_required_directly()
|
|
197
229
|
models_to_pause: set[str] = set()
|
|
198
230
|
for replica in waiting_longest_first:
|
|
199
231
|
models_to_pause |= self._fewest_idle_models_to_pause_for(
|
|
200
|
-
replica.required_resources,
|
|
232
|
+
replica.required_resources,
|
|
233
|
+
occupancy.nodes,
|
|
234
|
+
self._pause_groups(
|
|
235
|
+
running - models_to_pause,
|
|
236
|
+
{replica.replica_class_name}
|
|
237
|
+
| _reachable(replica.replica_class_name, required),
|
|
238
|
+
required,
|
|
239
|
+
),
|
|
201
240
|
)
|
|
202
241
|
return models_to_pause
|
|
203
242
|
|
|
243
|
+
def _models_required_directly(self) -> _ModelGraph:
|
|
244
|
+
model_of_app = {model.app_name: name for name, model in self._models.items()}
|
|
245
|
+
return {
|
|
246
|
+
name: {model_of_app[app] for app in model.required_apps if app in model_of_app}
|
|
247
|
+
for name, model in self._models.items()
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
def _pause_groups(
|
|
251
|
+
self, running: set[str], unpausable: set[str], required: _ModelGraph
|
|
252
|
+
) -> _ModelGraph:
|
|
253
|
+
dependents = _reversed(required)
|
|
254
|
+
groups: _ModelGraph = {}
|
|
255
|
+
for name in running:
|
|
256
|
+
group = {name} | (_reachable(name, dependents) & running)
|
|
257
|
+
if unpausable.isdisjoint(group) and not any(
|
|
258
|
+
self._models[member].has_requests for member in group
|
|
259
|
+
):
|
|
260
|
+
groups[name] = group
|
|
261
|
+
return groups
|
|
262
|
+
|
|
204
263
|
def _fewest_idle_models_to_pause_for(
|
|
205
264
|
self,
|
|
206
265
|
required_resources: _RayResources,
|
|
207
266
|
nodes: list[_NodeOccupancy],
|
|
208
|
-
|
|
267
|
+
pause_groups: _ModelGraph,
|
|
209
268
|
) -> set[str]:
|
|
210
269
|
fewest_models_to_pause: list[str] | None = None
|
|
211
270
|
for node in nodes:
|
|
212
271
|
if _has_room_for(required_resources, node.free_resources):
|
|
213
272
|
return set()
|
|
214
273
|
models_to_pause = self._least_recently_used_idle_models_freeing(
|
|
215
|
-
required_resources, node,
|
|
274
|
+
required_resources, node, pause_groups
|
|
216
275
|
)
|
|
217
276
|
if models_to_pause is not None and (
|
|
218
277
|
fewest_models_to_pause is None
|
|
@@ -225,23 +284,21 @@ class _ModelScheduler:
|
|
|
225
284
|
self,
|
|
226
285
|
required_resources: _RayResources,
|
|
227
286
|
node: _NodeOccupancy,
|
|
228
|
-
|
|
287
|
+
pause_groups: _ModelGraph,
|
|
229
288
|
) -> list[str] | None:
|
|
230
289
|
idle_models_least_recently_used_first = sorted(
|
|
231
|
-
(
|
|
232
|
-
name
|
|
233
|
-
for name in node.resources_held_by_model
|
|
234
|
-
if name not in already_pausing and not self._models[name].has_requests
|
|
235
|
-
),
|
|
290
|
+
(name for name in node.resources_held_by_model if name in pause_groups),
|
|
236
291
|
key=lambda name: self._models[name].last_request_at,
|
|
237
292
|
)
|
|
238
293
|
resources_free_after_pause = dict(node.free_resources)
|
|
239
294
|
models_to_pause: list[str] = []
|
|
240
295
|
for name in idle_models_least_recently_used_first:
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
296
|
+
for member in sorted(pause_groups[name] - set(models_to_pause)):
|
|
297
|
+
models_to_pause.append(member)
|
|
298
|
+
_add_resources(
|
|
299
|
+
resources_free_after_pause,
|
|
300
|
+
node.resources_held_by_model.get(member, {}),
|
|
301
|
+
)
|
|
245
302
|
if _has_room_for(required_resources, resources_free_after_pause):
|
|
246
303
|
return models_to_pause
|
|
247
304
|
return None
|
|
@@ -269,7 +326,11 @@ def _read_cluster_occupancy(scheduled_replica_class_names: set[str]) -> _Cluster
|
|
|
269
326
|
reserved_resources = actor_fields["required_resources"] or {}
|
|
270
327
|
if _is_serve_replica_waiting_for_room(actor_fields):
|
|
271
328
|
replicas_waiting_for_room.append(
|
|
272
|
-
_ReplicaWaitingForRoom(
|
|
329
|
+
_ReplicaWaitingForRoom(
|
|
330
|
+
actor_fields["actor_id"],
|
|
331
|
+
actor_fields["class_name"],
|
|
332
|
+
reserved_resources,
|
|
333
|
+
)
|
|
273
334
|
)
|
|
274
335
|
continue
|
|
275
336
|
occupancy = occupancy_by_node_id.get(actor_fields["node_id"])
|
|
@@ -305,6 +366,25 @@ def _is_serve_replica_waiting_for_room(actor_fields: dict[str, Any]) -> bool:
|
|
|
305
366
|
)
|
|
306
367
|
|
|
307
368
|
|
|
369
|
+
def _reachable(start: str, edges: _ModelGraph) -> set[str]:
|
|
370
|
+
reached: set[str] = set()
|
|
371
|
+
to_visit = list(edges.get(start, ()))
|
|
372
|
+
while to_visit:
|
|
373
|
+
name = to_visit.pop()
|
|
374
|
+
if name not in reached:
|
|
375
|
+
reached.add(name)
|
|
376
|
+
to_visit.extend(edges.get(name, ()))
|
|
377
|
+
return reached
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _reversed(edges: _ModelGraph) -> _ModelGraph:
|
|
381
|
+
reversed_edges: _ModelGraph = {}
|
|
382
|
+
for source, targets in edges.items():
|
|
383
|
+
for target in targets:
|
|
384
|
+
reversed_edges.setdefault(target, set()).add(source)
|
|
385
|
+
return reversed_edges
|
|
386
|
+
|
|
387
|
+
|
|
308
388
|
def _add_resources(target: _RayResources, amounts: _RayResources) -> None:
|
|
309
389
|
for name, amount in amounts.items():
|
|
310
390
|
target[name] = target.get(name, 0.0) + amount
|
|
@@ -77,7 +77,9 @@ def build(args: dict[str, Any]) -> Application:
|
|
|
77
77
|
# This builder ships in the bundle, frozen at save time, while `args`
|
|
78
78
|
# come from the cortexgrid that deploys it; one older than the bundle
|
|
79
79
|
# sends neither key.
|
|
80
|
-
autoscaling_config=model_autoscaling_config(
|
|
80
|
+
autoscaling_config=model_autoscaling_config(
|
|
81
|
+
args.get("num_replicas", 1), args.get("required_apps", [])
|
|
82
|
+
),
|
|
81
83
|
# Ray 2.32 lowered the default from 100 to 5; keep what serve-apps
|
|
82
84
|
# had on Ray 2.9.
|
|
83
85
|
max_ongoing_requests=_MAX_ONGOING_REQUESTS,
|
|
@@ -35,10 +35,14 @@ from cortexgrid.model_serving.deployment_key import (
|
|
|
35
35
|
deployment_key,
|
|
36
36
|
)
|
|
37
37
|
from cortexgrid.model_serving.lifecycle import (
|
|
38
|
+
Deployment,
|
|
39
|
+
DeploymentClient,
|
|
38
40
|
ModelDeployFailed,
|
|
39
41
|
ModelNotDeployed,
|
|
40
42
|
deploy_model,
|
|
43
|
+
list_deployed_models,
|
|
41
44
|
redeploy_model,
|
|
45
|
+
required_models,
|
|
42
46
|
undeploy_model,
|
|
43
47
|
wait_for_model_serving,
|
|
44
48
|
)
|
|
@@ -48,6 +52,7 @@ from cortexgrid.model_serving.registry_tags import (
|
|
|
48
52
|
has_requirement_tags,
|
|
49
53
|
metadata_from_tags,
|
|
50
54
|
metadata_to_tags,
|
|
55
|
+
required_models_to_tags,
|
|
51
56
|
requirements_from_tags,
|
|
52
57
|
requirements_to_tags,
|
|
53
58
|
)
|
|
@@ -60,12 +65,10 @@ from cortexgrid.model_serving.serve_bundle import (
|
|
|
60
65
|
upload_bundle,
|
|
61
66
|
)
|
|
62
67
|
from cortexgrid.model_serving.status import (
|
|
63
|
-
Deployment,
|
|
64
68
|
ReplicaPlacement,
|
|
65
69
|
ServingMessage,
|
|
66
70
|
ServingStatus,
|
|
67
71
|
deployment_config,
|
|
68
|
-
list_deployed_models,
|
|
69
72
|
model_replica_placements,
|
|
70
73
|
model_serving_messages,
|
|
71
74
|
model_serving_status,
|
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from typing import Any
|
|
4
4
|
|
|
5
|
-
from cortexgrid.model_serving.deployment_key import DeploymentKey
|
|
5
|
+
from cortexgrid.model_serving.deployment_key import DeploymentKey, deployment_key
|
|
6
6
|
from cortexgrid.model_serving.placement import ModelRequirements, ray_actor_options
|
|
7
7
|
from cortexgrid.model_serving.serve_bundle import (
|
|
8
8
|
BundleMetadata,
|
|
@@ -41,6 +41,22 @@ def build_application_spec(
|
|
|
41
41
|
runtime_env: dict[str, Any] = {"working_dir": meta.bundle_url}
|
|
42
42
|
if meta.pip_requirements:
|
|
43
43
|
runtime_env["pip"] = meta.pip_requirements
|
|
44
|
+
args: dict[str, Any] = {
|
|
45
|
+
"class_import_path": meta.class_import_path,
|
|
46
|
+
"family": key.family,
|
|
47
|
+
"suffix": key.suffix,
|
|
48
|
+
"run_name": key.run_name,
|
|
49
|
+
"config_fingerprint": key.config_fingerprint,
|
|
50
|
+
"num_replicas": num_replicas,
|
|
51
|
+
"ray_actor_options": ray_actor_options(requirements, tiers),
|
|
52
|
+
}
|
|
53
|
+
if requirements.models:
|
|
54
|
+
args["required_apps"] = [
|
|
55
|
+
app_name(
|
|
56
|
+
deployment_key(model.family, model.suffix, model.run_name, model.config)
|
|
57
|
+
)
|
|
58
|
+
for model in requirements.models
|
|
59
|
+
]
|
|
44
60
|
return {
|
|
45
61
|
"name": app_name(key),
|
|
46
62
|
"route_prefix": route_prefix(key),
|
|
@@ -49,15 +65,7 @@ def build_application_spec(
|
|
|
49
65
|
# Deployment class is rejected, so cortexgrid.deploy_model goes through
|
|
50
66
|
# a generic builder that re-imports the user's class and binds it.
|
|
51
67
|
"import_path": "cortexgrid._serve_entry:build",
|
|
52
|
-
"args":
|
|
53
|
-
"class_import_path": meta.class_import_path,
|
|
54
|
-
"family": key.family,
|
|
55
|
-
"suffix": key.suffix,
|
|
56
|
-
"run_name": key.run_name,
|
|
57
|
-
"config_fingerprint": key.config_fingerprint,
|
|
58
|
-
"num_replicas": num_replicas,
|
|
59
|
-
"ray_actor_options": ray_actor_options(requirements, tiers),
|
|
60
|
-
},
|
|
68
|
+
"args": args,
|
|
61
69
|
"runtime_env": runtime_env,
|
|
62
70
|
}
|
|
63
71
|
|
|
@@ -2,10 +2,27 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import hashlib
|
|
4
4
|
import json
|
|
5
|
-
from dataclasses import dataclass
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Callable
|
|
6
8
|
|
|
7
9
|
|
|
8
|
-
|
|
10
|
+
@dataclass
|
|
11
|
+
class DeploymentConfig:
|
|
12
|
+
family: str
|
|
13
|
+
suffix: str
|
|
14
|
+
run_name: str
|
|
15
|
+
num_replicas: int = 1
|
|
16
|
+
config: dict[str, str] = field(default_factory=dict)
|
|
17
|
+
serve_app: type | None = None
|
|
18
|
+
source: Callable[[], str | Path] | None = None
|
|
19
|
+
|
|
20
|
+
def __post_init__(self) -> None:
|
|
21
|
+
if self.source is not None and self.serve_app is None:
|
|
22
|
+
raise ValueError(
|
|
23
|
+
f"{self.family}/{self.suffix} has a source but no serve_app to front it"
|
|
24
|
+
)
|
|
25
|
+
|
|
9
26
|
|
|
10
27
|
_CONFIG_FINGERPRINT_LENGTH = 12
|
|
11
28
|
|
|
@@ -19,12 +36,12 @@ class DeploymentKey:
|
|
|
19
36
|
|
|
20
37
|
|
|
21
38
|
def deployment_key(
|
|
22
|
-
family: str, suffix: str, run_name: str, config:
|
|
39
|
+
family: str, suffix: str, run_name: str, config: dict[str, str]
|
|
23
40
|
) -> DeploymentKey:
|
|
24
41
|
return DeploymentKey(family, suffix, run_name, _config_fingerprint(config))
|
|
25
42
|
|
|
26
43
|
|
|
27
|
-
def _config_fingerprint(config:
|
|
44
|
+
def _config_fingerprint(config: dict[str, str]) -> str:
|
|
28
45
|
for name, value in config.items():
|
|
29
46
|
if not isinstance(name, str) or not isinstance(value, str):
|
|
30
47
|
raise ValueError(
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
4
|
+
import importlib
|
|
3
5
|
import time
|
|
4
|
-
from
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Generic, TypeVar
|
|
5
8
|
|
|
6
9
|
from ray.serve.schema import ApplicationStatus
|
|
7
10
|
|
|
@@ -22,6 +25,8 @@ from cortexgrid.model_serving.deployment_records import (
|
|
|
22
25
|
DeploymentRecord,
|
|
23
26
|
delete_deployment_record,
|
|
24
27
|
get_deployment_record,
|
|
28
|
+
key_of_record,
|
|
29
|
+
list_deployment_records,
|
|
25
30
|
patch_deployment_record,
|
|
26
31
|
put_deployment_record,
|
|
27
32
|
)
|
|
@@ -29,9 +34,8 @@ from cortexgrid.model_serving.placement import ModelRequirements, vram_tiers
|
|
|
29
34
|
from cortexgrid.model_serving.registry_tags import load_deploy_metadata
|
|
30
35
|
from cortexgrid.model_serving.serve_bundle import BundleMetadata
|
|
31
36
|
from cortexgrid.model_serving.status import (
|
|
37
|
+
PHASE_NOT_DEPLOYED,
|
|
32
38
|
PHASE_PAUSED,
|
|
33
|
-
Deployment,
|
|
34
|
-
deployment_of_record,
|
|
35
39
|
observed,
|
|
36
40
|
replaced_bundle_fingerprint_until_rolled_out,
|
|
37
41
|
)
|
|
@@ -77,6 +81,10 @@ def _past(deadline: float | None) -> bool:
|
|
|
77
81
|
return deadline is not None and time.monotonic() >= deadline
|
|
78
82
|
|
|
79
83
|
|
|
84
|
+
def _time_left(deadline: float | None) -> float | None:
|
|
85
|
+
return None if deadline is None else max(0.0, deadline - time.monotonic())
|
|
86
|
+
|
|
87
|
+
|
|
80
88
|
def wait_for_model_serving(key: DeploymentKey, timeout: float | None = None) -> None:
|
|
81
89
|
"""Block until the model's Serve app is RUNNING.
|
|
82
90
|
|
|
@@ -97,16 +105,12 @@ def _wait_for_application_running(
|
|
|
97
105
|
"""`wait_for_model_serving` against a deadline already running. `timeout`
|
|
98
106
|
only labels the TimeoutError."""
|
|
99
107
|
while True:
|
|
100
|
-
app =
|
|
101
|
-
if app is None:
|
|
102
|
-
raise ModelDeployFailed(f"Serve app {name!r} does not exist")
|
|
108
|
+
app = _application_still_able_to_serve(name)
|
|
103
109
|
status = str(app.get("status", "(missing)"))
|
|
104
|
-
message = str(app.get("message", ""))
|
|
105
110
|
if status == ApplicationStatus.RUNNING.value:
|
|
106
111
|
return
|
|
107
|
-
if status == ApplicationStatus.DEPLOY_FAILED.value:
|
|
108
|
-
raise ModelDeployFailed(f"Serve app {name!r} DEPLOY_FAILED: {message}")
|
|
109
112
|
if _past(deadline):
|
|
113
|
+
message = str(app.get("message", ""))
|
|
110
114
|
raise TimeoutError(
|
|
111
115
|
f"Serve app {name!r} did not reach RUNNING within {timeout}s "
|
|
112
116
|
f"(last status={status!r}, message={message!r})"
|
|
@@ -114,6 +118,90 @@ def _wait_for_application_running(
|
|
|
114
118
|
time.sleep(_SERVING_POLL_INTERVAL_S)
|
|
115
119
|
|
|
116
120
|
|
|
121
|
+
def _application_still_able_to_serve(name: str) -> dict[str, Any]:
|
|
122
|
+
serve_details = get_serve_details()
|
|
123
|
+
applications = serve_details.get("applications", {})
|
|
124
|
+
app: dict[str, Any] | None = applications.get(name)
|
|
125
|
+
if app is None:
|
|
126
|
+
raise ModelDeployFailed(f"Serve app {name!r} does not exist")
|
|
127
|
+
status = app.get("status")
|
|
128
|
+
if status == ApplicationStatus.DEPLOY_FAILED.value:
|
|
129
|
+
message = app.get("message", "")
|
|
130
|
+
raise ModelDeployFailed(f"Serve app {name!r} DEPLOY_FAILED: {message}")
|
|
131
|
+
return app
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _model_is_serving(key: DeploymentKey) -> bool:
|
|
135
|
+
name = app_name(key)
|
|
136
|
+
app = _application_still_able_to_serve(name)
|
|
137
|
+
status = app.get("status")
|
|
138
|
+
return status == ApplicationStatus.RUNNING.value
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass
|
|
142
|
+
class DeploymentClient:
|
|
143
|
+
key: DeploymentKey
|
|
144
|
+
url: str
|
|
145
|
+
|
|
146
|
+
async def is_ready(self) -> bool:
|
|
147
|
+
serving = await asyncio.to_thread(_model_is_serving, self.key)
|
|
148
|
+
return serving
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
DeploymentClientT = TypeVar("DeploymentClientT", bound=DeploymentClient)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass
|
|
155
|
+
class Deployment(Generic[DeploymentClientT]):
|
|
156
|
+
"""A scheduled Ray Serve app fronting a model. `phase` is the normalized
|
|
157
|
+
serving lifecycle phase (see `ServingStatus`); an app that appears in a
|
|
158
|
+
listing always exists, so its phase is never "not_deployed"."""
|
|
159
|
+
|
|
160
|
+
key: DeploymentKey
|
|
161
|
+
config: dict[str, str]
|
|
162
|
+
url: str
|
|
163
|
+
phase: str
|
|
164
|
+
bundle_fingerprint: str
|
|
165
|
+
replaced_bundle_fingerprint: str
|
|
166
|
+
experiment_name: str
|
|
167
|
+
class_import_path: str
|
|
168
|
+
|
|
169
|
+
def client(self) -> DeploymentClientT:
|
|
170
|
+
wait_for_model_serving(self.key)
|
|
171
|
+
deployment_client = self.client_async()
|
|
172
|
+
return deployment_client
|
|
173
|
+
|
|
174
|
+
def client_async(self) -> DeploymentClientT:
|
|
175
|
+
module_name, class_name = self.class_import_path.split(":")
|
|
176
|
+
serve_app_module = importlib.import_module(module_name)
|
|
177
|
+
serve_app = getattr(serve_app_module, class_name)
|
|
178
|
+
deployment_client: DeploymentClientT = serve_app.client(self)
|
|
179
|
+
return deployment_client
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def list_deployed_models() -> list[Deployment[Any]]:
|
|
183
|
+
"""Return a Deployment for every model `deploy_model` put on Ray Serve
|
|
184
|
+
whose app the control plane last saw existing."""
|
|
185
|
+
return [
|
|
186
|
+
deployment_of_record(record)
|
|
187
|
+
for record in list_deployment_records()
|
|
188
|
+
if record["phase"] != PHASE_NOT_DEPLOYED
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def deployment_of_record(record: DeploymentRecord) -> Deployment[Any]:
|
|
193
|
+
return Deployment(
|
|
194
|
+
key=key_of_record(record),
|
|
195
|
+
config=record["config"],
|
|
196
|
+
url=record["url"],
|
|
197
|
+
phase=record["phase"],
|
|
198
|
+
bundle_fingerprint=bundle_fingerprint_in_spec(record["spec"]),
|
|
199
|
+
replaced_bundle_fingerprint=record["replaced_bundle_fingerprint"],
|
|
200
|
+
experiment_name=record["experiment_name"],
|
|
201
|
+
class_import_path=record["spec"]["args"]["class_import_path"],
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
117
205
|
def _clear_failed_application(
|
|
118
206
|
key: DeploymentKey, timeout: float | None, deadline: float | None
|
|
119
207
|
) -> None:
|
|
@@ -219,13 +307,12 @@ def deploy_model(
|
|
|
219
307
|
num_replicas: int = 1,
|
|
220
308
|
wait: bool = False,
|
|
221
309
|
timeout: float | None = 300.0,
|
|
222
|
-
config:
|
|
310
|
+
config: dict[str, str] | None = None,
|
|
223
311
|
experiment_name: str = "",
|
|
224
|
-
) -> Deployment:
|
|
312
|
+
) -> Deployment[Any]:
|
|
225
313
|
"""Schedule a Ray Serve app for a previously-saved model and return a
|
|
226
|
-
handle
|
|
227
|
-
|
|
228
|
-
request schemas - against that URL; cortexgrid imposes no traffic contract.
|
|
314
|
+
handle on it. The handle's `client()` is the client the serve-app's own
|
|
315
|
+
`client` factory makes for its routes; cortexgrid imposes no traffic contract.
|
|
229
316
|
|
|
230
317
|
The serve-app class is pulled from the registry entry's tags `save_model`
|
|
231
318
|
wrote at save time; the caller does not need to hold the class object.
|
|
@@ -259,9 +346,20 @@ def deploy_model(
|
|
|
259
346
|
stuck in DEPLOYING) will hang forever.
|
|
260
347
|
"""
|
|
261
348
|
deadline = _deadline(timeout)
|
|
349
|
+
meta, requirements = load_deploy_metadata(family, suffix, run_name)
|
|
350
|
+
for model in requirements.models:
|
|
351
|
+
deploy_model(
|
|
352
|
+
model.family,
|
|
353
|
+
model.suffix,
|
|
354
|
+
model.run_name,
|
|
355
|
+
num_replicas=model.num_replicas,
|
|
356
|
+
wait=True,
|
|
357
|
+
timeout=_time_left(deadline),
|
|
358
|
+
config=model.config,
|
|
359
|
+
experiment_name=experiment_name,
|
|
360
|
+
)
|
|
262
361
|
deployment_config = config or {}
|
|
263
362
|
key = deployment_key(family, suffix, run_name, deployment_config)
|
|
264
|
-
meta, requirements = load_deploy_metadata(family, suffix, run_name)
|
|
265
363
|
record = get_deployment_record(key)
|
|
266
364
|
if record is not None and _record_is_current(
|
|
267
365
|
record, key, meta, requirements, num_replicas
|
|
@@ -317,10 +415,11 @@ def deploy_model(
|
|
|
317
415
|
bundle_fingerprint=meta.fingerprint,
|
|
318
416
|
replaced_bundle_fingerprint=observation["replaced_bundle_fingerprint"],
|
|
319
417
|
experiment_name=experiment_name,
|
|
418
|
+
class_import_path=meta.class_import_path,
|
|
320
419
|
)
|
|
321
420
|
|
|
322
421
|
|
|
323
|
-
def redeploy_model(key: DeploymentKey) -> Deployment:
|
|
422
|
+
def redeploy_model(key: DeploymentKey) -> Deployment[Any]:
|
|
324
423
|
record = get_deployment_record(key)
|
|
325
424
|
if record is None:
|
|
326
425
|
raise ModelNotDeployed(f"{key} is not deployed")
|
|
@@ -334,6 +433,21 @@ def redeploy_model(key: DeploymentKey) -> Deployment:
|
|
|
334
433
|
)
|
|
335
434
|
|
|
336
435
|
|
|
436
|
+
def required_models(deployment: DeploymentKey) -> list[Deployment[Any]]:
|
|
437
|
+
_, requirements = load_deploy_metadata(
|
|
438
|
+
deployment.family, deployment.suffix, deployment.run_name
|
|
439
|
+
)
|
|
440
|
+
return [_deployment_of(model) for model in requirements.models]
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _deployment_of(model: DeploymentConfig) -> Deployment[Any]:
|
|
444
|
+
key = deployment_key(model.family, model.suffix, model.run_name, model.config)
|
|
445
|
+
record = get_deployment_record(key)
|
|
446
|
+
if record is None:
|
|
447
|
+
raise ModelNotDeployed(f"{key} is not deployed")
|
|
448
|
+
return deployment_of_record(record)
|
|
449
|
+
|
|
450
|
+
|
|
337
451
|
def undeploy_model(key: DeploymentKey) -> None:
|
|
338
452
|
"""Tear down the Ray Serve app of this deployment and drop its record."""
|
|
339
453
|
name = app_name(key)
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from dataclasses import dataclass
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
4
|
from typing import Any
|
|
5
5
|
|
|
6
|
+
from cortexgrid.model_serving.deployment_key import DeploymentConfig
|
|
6
7
|
from cortexgrid.ray_util import get_ray_nodes
|
|
7
8
|
|
|
8
9
|
|
|
@@ -24,6 +25,7 @@ class ModelRequirements:
|
|
|
24
25
|
ram_gb: float = 0.0
|
|
25
26
|
# GPU memory across the replica's num_gpus GPUs, so it needs a GPU share.
|
|
26
27
|
vram_gb: float = 0.0
|
|
28
|
+
models: list[DeploymentConfig] = field(default_factory=list)
|
|
27
29
|
|
|
28
30
|
def __post_init__(self) -> None:
|
|
29
31
|
if self.num_gpus < 0 or self.ram_gb < 0 or self.vram_gb < 0:
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import json
|
|
4
4
|
|
|
5
5
|
from cortexgrid import state
|
|
6
|
+
from cortexgrid.model_serving.deployment_key import DeploymentConfig
|
|
6
7
|
from cortexgrid.model_serving.placement import ModelRequirements
|
|
7
8
|
from cortexgrid.model_serving.serve_bundle import BundleMetadata
|
|
8
9
|
|
|
@@ -48,6 +49,7 @@ def bundle_fingerprint_from_tags(tags: dict[str, str]) -> str:
|
|
|
48
49
|
_NUM_GPUS_TAG = "num_gpus"
|
|
49
50
|
_RAM_GB_TAG = "ram_gb"
|
|
50
51
|
_VRAM_GB_TAG = "vram_gb"
|
|
52
|
+
_MODELS_TAG = "models"
|
|
51
53
|
|
|
52
54
|
|
|
53
55
|
def has_requirement_tags(tags: dict[str, str]) -> bool:
|
|
@@ -62,6 +64,24 @@ def requirements_to_tags(requirements: ModelRequirements) -> dict[str, str]:
|
|
|
62
64
|
_NUM_GPUS_TAG: str(requirements.num_gpus),
|
|
63
65
|
_RAM_GB_TAG: str(requirements.ram_gb),
|
|
64
66
|
_VRAM_GB_TAG: str(requirements.vram_gb),
|
|
67
|
+
**required_models_to_tags(requirements.models),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def required_models_to_tags(models: list[DeploymentConfig]) -> dict[str, str]:
|
|
72
|
+
return {
|
|
73
|
+
_MODELS_TAG: json.dumps(
|
|
74
|
+
[
|
|
75
|
+
{
|
|
76
|
+
"family": model.family,
|
|
77
|
+
"suffix": model.suffix,
|
|
78
|
+
"run_name": model.run_name,
|
|
79
|
+
"num_replicas": model.num_replicas,
|
|
80
|
+
"config": model.config,
|
|
81
|
+
}
|
|
82
|
+
for model in models
|
|
83
|
+
]
|
|
84
|
+
)
|
|
65
85
|
}
|
|
66
86
|
|
|
67
87
|
|
|
@@ -74,6 +94,9 @@ def requirements_from_tags(tags: dict[str, str]) -> ModelRequirements:
|
|
|
74
94
|
num_gpus=float(tags.get(_NUM_GPUS_TAG, "0")),
|
|
75
95
|
ram_gb=float(tags.get(_RAM_GB_TAG, "0")),
|
|
76
96
|
vram_gb=float(tags.get(_VRAM_GB_TAG, "0")),
|
|
97
|
+
models=[
|
|
98
|
+
DeploymentConfig(**model) for model in json.loads(tags.get(_MODELS_TAG, "[]"))
|
|
99
|
+
],
|
|
77
100
|
)
|
|
78
101
|
|
|
79
102
|
|
|
@@ -55,12 +55,19 @@ def build_bundle(cls: type) -> ServeBundle:
|
|
|
55
55
|
is a subclass Ray defines in its own module, and on older Ray (e.g. 2.9) it
|
|
56
56
|
reports that module as its own, so the class's source and import path would
|
|
57
57
|
resolve to Ray instead of the serve-app. `cortexgrid.serve.ingress` leaves
|
|
58
|
-
the class unwrapped.
|
|
58
|
+
the class unwrapped. Raises ValueError, too, for a class with no `client`
|
|
59
|
+
factory, which every `Deployment` of it makes its client with."""
|
|
59
60
|
if any(klass.__module__.startswith("ray.serve") for klass in cls.__mro__):
|
|
60
61
|
raise ValueError(
|
|
61
62
|
f"{cls.__name__} is wrapped by ray.serve.ingress; decorate it with "
|
|
62
63
|
"cortexgrid.serve.ingress instead (from cortexgrid import serve)"
|
|
63
64
|
)
|
|
65
|
+
if not callable(getattr(cls, "client", None)):
|
|
66
|
+
raise ValueError(
|
|
67
|
+
f"{cls.__name__} has no client factory; give it a classmethod "
|
|
68
|
+
"client(cls, deployment: cortexgrid.Deployment) returning its "
|
|
69
|
+
"cortexgrid.DeploymentClient"
|
|
70
|
+
)
|
|
64
71
|
entry_file = Path(inspect.getfile(cls)).resolve()
|
|
65
72
|
serve_entry = Path(__file__).parent.with_name("_serve_entry.py")
|
|
66
73
|
desc = bundle(entry_file).merge(bundle(serve_entry))
|
|
@@ -6,13 +6,9 @@ from typing import Any
|
|
|
6
6
|
|
|
7
7
|
from ray.serve.schema import ApplicationStatus, ReplicaState
|
|
8
8
|
|
|
9
|
-
from cortexgrid.model_serving.application_spec import
|
|
10
|
-
|
|
11
|
-
bundle_fingerprint_in_spec,
|
|
12
|
-
)
|
|
13
|
-
from cortexgrid.model_serving.deployment_key import DeploymentConfig, DeploymentKey
|
|
9
|
+
from cortexgrid.model_serving.application_spec import app_name
|
|
10
|
+
from cortexgrid.model_serving.deployment_key import DeploymentKey
|
|
14
11
|
from cortexgrid.model_serving.deployment_records import (
|
|
15
|
-
DeploymentRecord,
|
|
16
12
|
get_deployment_record,
|
|
17
13
|
key_of_record,
|
|
18
14
|
list_deployment_records,
|
|
@@ -21,7 +17,7 @@ from cortexgrid.model_serving.deployment_records import (
|
|
|
21
17
|
from cortexgrid.ray_util import get_serve_details
|
|
22
18
|
|
|
23
19
|
|
|
24
|
-
|
|
20
|
+
PHASE_NOT_DEPLOYED = "not_deployed"
|
|
25
21
|
PHASE_PAUSED = "paused"
|
|
26
22
|
_ROLLED_OUT_PHASES = ("running", PHASE_PAUSED)
|
|
27
23
|
|
|
@@ -44,50 +40,13 @@ def _serve_phase(raw_status: str) -> str:
|
|
|
44
40
|
return _PHASE_BY_SERVE_STATUS.get(raw_status, "deploying")
|
|
45
41
|
|
|
46
42
|
|
|
47
|
-
@dataclass
|
|
48
|
-
class Deployment:
|
|
49
|
-
"""A scheduled Ray Serve app fronting a model. `phase` is the normalized
|
|
50
|
-
serving lifecycle phase (see `ServingStatus`); an app that appears in a
|
|
51
|
-
listing always exists, so its phase is never "not_deployed"."""
|
|
52
|
-
|
|
53
|
-
key: DeploymentKey
|
|
54
|
-
config: DeploymentConfig
|
|
55
|
-
url: str
|
|
56
|
-
phase: str
|
|
57
|
-
bundle_fingerprint: str
|
|
58
|
-
replaced_bundle_fingerprint: str
|
|
59
|
-
experiment_name: str
|
|
60
|
-
|
|
61
|
-
|
|
62
43
|
def replaced_bundle_fingerprint_until_rolled_out(
|
|
63
44
|
replaced_bundle_fingerprint: str, phase: str
|
|
64
45
|
) -> str:
|
|
65
46
|
return "" if phase in _ROLLED_OUT_PHASES else replaced_bundle_fingerprint
|
|
66
47
|
|
|
67
48
|
|
|
68
|
-
def
|
|
69
|
-
"""Return a Deployment for every model `deploy_model` put on Ray Serve
|
|
70
|
-
whose app the control plane last saw existing."""
|
|
71
|
-
return [
|
|
72
|
-
deployment_of_record(record)
|
|
73
|
-
for record in list_deployment_records()
|
|
74
|
-
if record["phase"] != _PHASE_NOT_DEPLOYED
|
|
75
|
-
]
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def deployment_of_record(record: DeploymentRecord) -> Deployment:
|
|
79
|
-
return Deployment(
|
|
80
|
-
key=key_of_record(record),
|
|
81
|
-
config=record["config"],
|
|
82
|
-
url=record["url"],
|
|
83
|
-
phase=record["phase"],
|
|
84
|
-
bundle_fingerprint=bundle_fingerprint_in_spec(record["spec"]),
|
|
85
|
-
replaced_bundle_fingerprint=record["replaced_bundle_fingerprint"],
|
|
86
|
-
experiment_name=record["experiment_name"],
|
|
87
|
-
)
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
def deployment_config(key: DeploymentKey) -> DeploymentConfig:
|
|
49
|
+
def deployment_config(key: DeploymentKey) -> dict[str, str]:
|
|
91
50
|
record = get_deployment_record(key)
|
|
92
51
|
if record is None:
|
|
93
52
|
raise ValueError(f"No deployment {key}")
|
|
@@ -134,8 +93,8 @@ def model_serving_status(key: DeploymentKey) -> ServingStatus:
|
|
|
134
93
|
`cortexgrid.model_storage.model_registry_status`.
|
|
135
94
|
"""
|
|
136
95
|
record = get_deployment_record(key)
|
|
137
|
-
if record is None or record["phase"] ==
|
|
138
|
-
return ServingStatus(key,
|
|
96
|
+
if record is None or record["phase"] == PHASE_NOT_DEPLOYED:
|
|
97
|
+
return ServingStatus(key, PHASE_NOT_DEPLOYED, "", None)
|
|
139
98
|
return ServingStatus(
|
|
140
99
|
key=key,
|
|
141
100
|
phase=record["phase"],
|
|
@@ -193,7 +152,7 @@ def observed(app: dict[str, Any] | None) -> dict[str, Any]:
|
|
|
193
152
|
deployment record keeps; an app that does not exist reads as
|
|
194
153
|
"not_deployed"."""
|
|
195
154
|
if app is None:
|
|
196
|
-
return {"phase":
|
|
155
|
+
return {"phase": PHASE_NOT_DEPLOYED, "message": "", "replicas": []}
|
|
197
156
|
raw = str(app.get("status", ""))
|
|
198
157
|
return {
|
|
199
158
|
"phase": _phase(app),
|
|
@@ -8,7 +8,8 @@ One registry entry (a `models` row) per (family, suffix, run_name):
|
|
|
8
8
|
"s3://<bucket>/models/<run_name>/<family>/<suffix>/weights/"
|
|
9
9
|
(NO_WEIGHTS for a model registered without any)
|
|
10
10
|
run linkage -> run_id (unset for imported models)
|
|
11
|
-
requirements -> tags["num_gpus"], ["ram_gb"], ["vram_gb"]
|
|
11
|
+
requirements -> tags["num_gpus"], ["ram_gb"], ["vram_gb"], ["models"]
|
|
12
|
+
(models: JSON list)
|
|
12
13
|
config -> tags["config"] (JSON object)
|
|
13
14
|
|
|
14
15
|
Three ways in: `save_model` registers a fresh copy under the calling run's
|
|
@@ -28,9 +29,11 @@ cortexgrid/__init__.py.
|
|
|
28
29
|
|
|
29
30
|
from __future__ import annotations
|
|
30
31
|
|
|
32
|
+
import fcntl
|
|
31
33
|
import json
|
|
32
34
|
import os
|
|
33
|
-
|
|
35
|
+
import shutil
|
|
36
|
+
from dataclasses import dataclass, field, replace
|
|
34
37
|
from datetime import datetime, timedelta, timezone
|
|
35
38
|
from pathlib import Path
|
|
36
39
|
import tempfile
|
|
@@ -48,6 +51,7 @@ from cortexgrid.model_serving import (
|
|
|
48
51
|
has_requirement_tags,
|
|
49
52
|
metadata_from_tags,
|
|
50
53
|
metadata_to_tags,
|
|
54
|
+
required_models_to_tags,
|
|
51
55
|
requirements_from_tags,
|
|
52
56
|
requirements_to_tags,
|
|
53
57
|
upload_bundle,
|
|
@@ -93,6 +97,9 @@ IMPORTED = "imported"
|
|
|
93
97
|
# why there is no path instead of an empty one.
|
|
94
98
|
NO_WEIGHTS = "cortexgrid://no-weights"
|
|
95
99
|
|
|
100
|
+
_MODEL_CACHE_DIR_ENV = "CORTEXGRID_MODEL_CACHE_DIR"
|
|
101
|
+
_DEFAULT_MODEL_CACHE_DIR = Path.home() / ".cache" / "cortexgrid" / "models"
|
|
102
|
+
|
|
96
103
|
|
|
97
104
|
@dataclass
|
|
98
105
|
class SavedModel:
|
|
@@ -223,6 +230,23 @@ def _download_s3_uri(uri: str, dest_dir: str | Path | None) -> Path:
|
|
|
223
230
|
return dest
|
|
224
231
|
|
|
225
232
|
|
|
233
|
+
def _model_cache_dir() -> Path:
|
|
234
|
+
return Path(os.environ.get(_MODEL_CACHE_DIR_ENV, _DEFAULT_MODEL_CACHE_DIR))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _load_imported_weights(uri: str, family: str, suffix: str) -> Path:
|
|
238
|
+
cached = _model_cache_dir() / family / suffix
|
|
239
|
+
cached.parent.mkdir(parents=True, exist_ok=True)
|
|
240
|
+
with open(cached.with_name(f"{suffix}.lock"), "w") as lock:
|
|
241
|
+
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
242
|
+
if not cached.is_dir():
|
|
243
|
+
partial = cached.with_name(f"{suffix}.partial")
|
|
244
|
+
shutil.rmtree(partial, ignore_errors=True)
|
|
245
|
+
_download_s3_uri(uri, partial)
|
|
246
|
+
partial.rename(cached)
|
|
247
|
+
return cached
|
|
248
|
+
|
|
249
|
+
|
|
226
250
|
def save_model(
|
|
227
251
|
weights_dir: str | Path,
|
|
228
252
|
serve_app: type,
|
|
@@ -292,8 +316,9 @@ def import_model(
|
|
|
292
316
|
longer matches the stored bundle, it is re-bundled first and the
|
|
293
317
|
weights are kept (see `_refresh_bundle`). `requirements` and `config`
|
|
294
318
|
are stored only if the version has none yet, so values changed since
|
|
295
|
-
with `set_model_requirements` / `set_model_config` are kept
|
|
296
|
-
|
|
319
|
+
with `set_model_requirements` / `set_model_config` are kept; the
|
|
320
|
+
required models in `requirements` replace the stored ones regardless.
|
|
321
|
+
To replace the weights, `delete_model` it first.
|
|
297
322
|
- "uploading": raises RuntimeError - another process is importing it.
|
|
298
323
|
- "upload_failed" / "broken": deleted and imported again.
|
|
299
324
|
|
|
@@ -386,11 +411,13 @@ def _refresh_bundle(serve_app: type, family: str, suffix: str) -> None:
|
|
|
386
411
|
def _set_missing_requirements(
|
|
387
412
|
family: str, suffix: str, requirements: ModelRequirements
|
|
388
413
|
) -> ModelRequirements:
|
|
389
|
-
"""Store `requirements` on an imported model whose version has none yet
|
|
414
|
+
"""Store `requirements` on an imported model whose version has none yet;
|
|
415
|
+
its required models are stored either way, since only code declares them.
|
|
390
416
|
Returns the requirements the version holds afterwards."""
|
|
391
417
|
version = _get_version(family, suffix, IMPORTED)
|
|
392
418
|
if has_requirement_tags(version.tags):
|
|
393
|
-
|
|
419
|
+
_set_tags(family, suffix, IMPORTED, required_models_to_tags(requirements.models))
|
|
420
|
+
return replace(requirements_from_tags(version.tags), models=requirements.models)
|
|
394
421
|
_set_tags(family, suffix, IMPORTED, requirements_to_tags(requirements))
|
|
395
422
|
return requirements
|
|
396
423
|
|
|
@@ -499,6 +526,8 @@ def load_model(family: str, suffix: str, run_name: str) -> Path:
|
|
|
499
526
|
f"Model {family}/{suffix}/{run_name} was registered without "
|
|
500
527
|
"weights; there is nothing to load"
|
|
501
528
|
)
|
|
529
|
+
if run_name == IMPORTED:
|
|
530
|
+
return _load_imported_weights(version.source, family, suffix)
|
|
502
531
|
return _download_s3_uri(version.source, None)
|
|
503
532
|
|
|
504
533
|
|
|
@@ -185,8 +185,15 @@ from fastapi import FastAPI
|
|
|
185
185
|
|
|
186
186
|
app = FastAPI()
|
|
187
187
|
|
|
188
|
+
class MyClient(cortexgrid.DeploymentClient):
|
|
189
|
+
def complete(self, prompt: str) -> str: ...
|
|
190
|
+
|
|
188
191
|
@serve.ingress(app)
|
|
189
192
|
class MyServeApp:
|
|
193
|
+
@classmethod
|
|
194
|
+
def client(cls, deployment: cortexgrid.Deployment[MyClient]) -> MyClient:
|
|
195
|
+
return MyClient(key=deployment.key, url=deployment.url)
|
|
196
|
+
|
|
190
197
|
def __init__(self, deployment: cortexgrid.DeploymentKey) -> None:
|
|
191
198
|
self._weights_dir = cortexgrid.load_model(
|
|
192
199
|
deployment.family, deployment.suffix, deployment.run_name
|
|
@@ -200,8 +207,8 @@ saved = cortexgrid.save_model(
|
|
|
200
207
|
# What one replica needs; the model is deployed only on a host that has it.
|
|
201
208
|
requirements=cortexgrid.ModelRequirements(num_gpus=1, ram_gb=8, vram_gb=16),
|
|
202
209
|
)
|
|
203
|
-
deployed = cortexgrid.deploy_model("qwen", "instruct", saved.run_name
|
|
204
|
-
|
|
210
|
+
deployed = cortexgrid.deploy_model("qwen", "instruct", saved.run_name)
|
|
211
|
+
model = deployed.client() # MyServeApp's client, once the app serves
|
|
205
212
|
```
|
|
206
213
|
|
|
207
214
|
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. Among the hosts that fit, the model goes to the **smallest GPU** that does, so a 4 GiB model does not occupy a 128 GiB card a bigger one needs; it moves up only once the smaller cards are full. `num_gpus` may be a fraction (`0.25`) to share one card between models, in which case `vram_gb` is what keeps them from overcommitting it. 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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|