cortexgrid 0.2.85__tar.gz → 0.2.86__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.85
3
+ Version: 0.2.86
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
@@ -13,6 +13,7 @@ Requires-Dist: cloudpickle>=3.0
13
13
  Requires-Dist: fabric>=3.2.3
14
14
  Requires-Dist: haikunator>=2.1.0
15
15
  Requires-Dist: mlflow<4,>=3.11
16
+ Requires-Dist: packaging>=24
16
17
  Requires-Dist: pip>=23.0
17
18
  Requires-Dist: pydantic-settings>=2.13.1
18
19
  Requires-Dist: pydantic>=2.13.3
@@ -115,9 +116,10 @@ print(f"Submitted: {job_id}")
115
116
  A separate service — the **jobs control plane** — polls MLflow for pending job requests, matches them against the set of Ray submissions the cluster already has, and submits anything missing. It is also responsible for retrying failed jobs and honouring user-requested stops.
116
117
 
117
118
  Each submission captures the code and dependencies the entry function needs automatically ([_bundle.py](https://github.com/robodatalab/cortexgrid/blob/main/cortexgrid/_bundle.py)):
118
- - `bundle(entry)` traces the import graph from the function's source file, resolving each import the way the interpreter does (via `sys.path`), and returns every file needed to run it -- your own modules and third-party packages alike, wherever they live. The standard library is excluded (it ships with the interpreter)
119
- - Everything ships **as source**: the bundle is staged at each file's import path and tarred into the Ray `working_dir`. Nothing is `pip`-installed on the worker
120
- - Dependencies the worker image already has are subtracted rather than shipped: `bundle(entry) - worker_provides()`, where `worker_provides()` is the bundle of the packages baked into the ray image (torch and its CUDA stack, ray, mlflow, ...). See [k8s/docker/ray/Dockerfile](https://github.com/robodatalab/cortexgrid/blob/main/k8s/docker/ray/Dockerfile)
119
+ - `bundle(entry)` traces the import graph from the function's source file, resolving each import the way the interpreter does (via `sys.path`). The standard library is excluded (it ships with the interpreter)
120
+ - Your own modules -- anything outside site-packages / dist-packages -- ship **as source**: they are staged at their import paths and tarred into the Ray `working_dir`
121
+ - Third-party packages are recorded as the installed distribution that owns the imported file, pinned to its installed version (`tqdm==4.67.3`), and Ray **pip-installs** them on the worker into a per-node cached virtualenv layered on the image (`runtime_env["pip"]`). An import into site-packages that no installed distribution owns fails `cortexgrid.remote` with `UnownedDependencyError`
122
+ - Distributions the worker image already has are not installed again: `worker_provides()` is the dependency closure of the packages baked into the ray image (torch and its CUDA stack, ray, mlflow, ...), and is subtracted from the pip list. See [k8s/docker/ray/Dockerfile](https://github.com/robodatalab/cortexgrid/blob/main/k8s/docker/ray/Dockerfile) and `_WORKER_BAKED` in `_bundle.py`, which must list the same packages
121
123
  - Injects MLflow/S3 credentials so task code running on the DGX can reach all services
122
124
 
123
125
  ##### Retries
@@ -3,27 +3,38 @@
3
3
  `bundle(seed)` describes what is needed to run the module at `seed`: the local
4
4
  files it reaches -- following its import graph and each package's __init__
5
5
  chain, resolving imports the way the interpreter does -- and the third-party
6
- dependencies those files import. A file is local unless it lives in an installed
7
- package location (site-packages / dist-packages); imports that resolve into one
8
- are not followed. The standard library is excluded (it ships with the
9
- interpreter). Bundles of several seeds combine with `BundleDesc.merge`.
6
+ distributions those files import. A file is local unless it lives in an installed
7
+ package location (site-packages / dist-packages). An import that resolves into
8
+ one is recorded as the distribution that installed the file, at its installed
9
+ version, and is not followed: installing that distribution brings its own
10
+ dependencies. The standard library is excluded (it ships with the interpreter).
11
+ Bundles of several seeds combine with `BundleDesc.merge`.
10
12
 
11
13
  `stage(files, dest)` lays a bundle out under `dest` at each file's import path,
12
14
  so `dest` on sys.path (e.g. a Ray working_dir) makes every module importable.
15
+
16
+ `BundleDesc.pip_requirements(worker_provides())` pins the third-party
17
+ distributions the Ray worker image does not already have, for a Ray `pip`
18
+ runtime_env to install on the worker.
13
19
  """
14
20
 
15
21
  from __future__ import annotations
16
22
 
17
23
  import ast
18
- from collections.abc import Iterator
24
+ from collections.abc import Iterable, Iterator
19
25
  from dataclasses import dataclass
20
26
  import functools
21
27
  import importlib.machinery
28
+ import importlib.metadata
22
29
  import importlib.util
30
+ import os
23
31
  from pathlib import Path
24
32
  import shutil
25
33
  import sys
26
34
 
35
+ from packaging.requirements import Requirement
36
+ from packaging.utils import canonicalize_name
37
+
27
38
 
28
39
  ThirdPartyDependencyName = str
29
40
  ThirdPartyDependencyVersion = str
@@ -39,17 +50,43 @@ class BundleDesc:
39
50
  tp_deps={**self.tp_deps, **other.tp_deps},
40
51
  )
41
52
 
53
+ def pip_requirements(
54
+ self, provided: frozenset[ThirdPartyDependencyName]
55
+ ) -> list[str]:
56
+ """The third-party distributions, pinned (`name==version`) and sorted,
57
+ minus the `provided` ones."""
58
+ return sorted(
59
+ f"{name}=={version}"
60
+ for name, version in self.tp_deps.items()
61
+ if name not in provided
62
+ )
63
+
64
+
65
+ class UnownedDependencyError(LookupError):
66
+ """An import resolved into an installed package location, but no installed
67
+ distribution owns the file, so it can be neither shipped nor installed."""
68
+
42
69
 
43
70
  def bundle(seed: Path) -> BundleDesc:
44
71
  """What is needed to run the module at `seed`: its local files, each at its
45
- real path. Third-party dependency detection is not implemented yet, so
46
- `tp_deps` is always empty."""
72
+ real path, and the third-party distributions they import, keyed by
73
+ canonical name, each at its installed version.
74
+
75
+ Raises UnownedDependencyError for an import that resolves into an installed
76
+ package location no distribution owns."""
47
77
  seed = seed.resolve()
48
78
  files: set[Path] = set()
79
+ tp_deps: dict[ThirdPartyDependencyName, ThirdPartyDependencyVersion] = {}
80
+ visited: set[Path] = set()
49
81
  queue: list[Path] = [seed]
50
82
  while queue:
51
83
  file = queue.pop()
52
- if file in files or not _is_local(file):
84
+ if file in visited:
85
+ continue
86
+ visited.add(file)
87
+ if not _is_local(file):
88
+ dist = _owning_distribution(file)
89
+ tp_deps[canonicalize_name(dist.metadata["Name"])] = dist.version
53
90
  continue
54
91
  files.add(file)
55
92
  queue.extend(_init_chain(file)) # importing a module runs its __init__ chain
@@ -58,7 +95,7 @@ def bundle(seed: Path) -> BundleDesc:
58
95
  dep = _module_file(name)
59
96
  if dep is not None:
60
97
  queue.append(dep)
61
- return BundleDesc(local_files=files, tp_deps={})
98
+ return BundleDesc(local_files=files, tp_deps=tp_deps)
62
99
 
63
100
 
64
101
  def stage(files: set[Path], dest: Path) -> None:
@@ -70,23 +107,120 @@ def stage(files: set[Path], dest: Path) -> None:
70
107
  shutil.copy2(file, target)
71
108
 
72
109
 
73
- # Distributions already present in the Ray worker image (k8s/docker/ray/Dockerfile).
74
- # They and their whole dependency trees -- torch's CUDA stack, sympy, numpy, ray,
75
- # mlflow, ... -- are on the worker already, so a caller subtracts them from a
76
- # bundle rather than shipping them again.
77
- _WORKER_BAKED = ("ray", "mlflow", "torch", "smart_open", "dotenv", "psutil")
110
+ # What the Ray worker image pip-installs, as the Dockerfile spells it. They and
111
+ # their dependency trees are on the worker already, so they are never installed
112
+ # there again -- a second copy in the job's virtualenv would shadow the image's.
113
+ #
114
+ # KEEP IN SYNC with k8s/docker/ray/Dockerfile, by hand: every package it
115
+ # pip-installs must be listed here. A package missing here gets installed a
116
+ # second time on the worker; one listed here but no longer in the image is
117
+ # never installed at all.
118
+ _WORKER_BAKED = (
119
+ "ray[default,serve]",
120
+ "smart_open[s3]",
121
+ "mlflow",
122
+ "python-dotenv",
123
+ "psutil",
124
+ "nvidia-cublas-cu12",
125
+ "nvidia-cudnn-cu12",
126
+ "nvidia-cuda-nvrtc-cu12",
127
+ "nvidia-cuda-runtime-cu12",
128
+ "nvidia-cuda-cupti-cu12",
129
+ "nvidia-cufft-cu12",
130
+ "nvidia-curand-cu12",
131
+ "nvidia-cusolver-cu12",
132
+ "nvidia-cusparse-cu12",
133
+ "nvidia-cusparselt-cu12",
134
+ "nvidia-nccl-cu12",
135
+ "nvidia-nvshmem-cu12",
136
+ "nvidia-nvtx-cu12",
137
+ "nvidia-nvjitlink-cu12",
138
+ "nvidia-cufile-cu12",
139
+ "cuda-bindings",
140
+ "triton",
141
+ "torch",
142
+ "filelock",
143
+ "typing-extensions",
144
+ "sympy",
145
+ "networkx",
146
+ "jinja2",
147
+ "fsspec",
148
+ )
149
+
150
+
151
+ @functools.lru_cache(maxsize=1)
152
+ def worker_provides() -> frozenset[ThirdPartyDependencyName]:
153
+ """Every distribution the Ray worker image already provides:
154
+ `distribution_closure(_WORKER_BAKED)`."""
155
+ return distribution_closure(_WORKER_BAKED)
156
+
157
+
158
+ def distribution_closure(
159
+ requirements: Iterable[str],
160
+ ) -> frozenset[ThirdPartyDependencyName]:
161
+ """Canonical names of the distributions `requirements` name plus everything
162
+ they depend on, transitively, as this environment's installed metadata
163
+ declares it -- extras followed where requested, environment markers
164
+ evaluated here. A requirement not installed here contributes only its own
165
+ name: its dependencies are unknown."""
166
+ names: set[ThirdPartyDependencyName] = set()
167
+ seen: set[tuple[str, frozenset[str]]] = set()
168
+ queue = [Requirement(spec) for spec in requirements]
169
+ while queue:
170
+ requirement = queue.pop()
171
+ name = canonicalize_name(requirement.name)
172
+ key = (name, frozenset(requirement.extras))
173
+ if key in seen:
174
+ continue
175
+ seen.add(key)
176
+ names.add(name)
177
+ try:
178
+ dist = importlib.metadata.distribution(name)
179
+ except importlib.metadata.PackageNotFoundError:
180
+ continue
181
+ extras = requirement.extras or {""}
182
+ for spec in dist.requires or ():
183
+ dependency = Requirement(spec)
184
+ if dependency.marker is None or any(
185
+ dependency.marker.evaluate({"extra": extra}) for extra in extras
186
+ ):
187
+ queue.append(dependency)
188
+ return frozenset(names)
189
+
190
+
191
+ def _owning_distribution(file: Path) -> importlib.metadata.Distribution:
192
+ """The installed distribution whose file list (RECORD) contains `file`.
193
+ Rebuilds the index once on a miss, in case something was installed since it
194
+ was built."""
195
+ path = tuple(sys.path)
196
+ dist = _distribution_index(path).get(file)
197
+ if dist is None:
198
+ _distribution_index.cache_clear()
199
+ dist = _distribution_index(path).get(file)
200
+ if dist is None:
201
+ raise UnownedDependencyError(
202
+ f"{file} is imported from an installed package location, but no "
203
+ "installed distribution lists it among its files, so it can be "
204
+ "neither shipped nor pip-installed on the worker. Install it with "
205
+ "pip or uv so it carries distribution metadata."
206
+ )
207
+ return dist
78
208
 
79
209
 
80
210
  @functools.lru_cache(maxsize=1)
81
- def worker_provides() -> frozenset[Path]:
82
- """Every file the Ray worker image already provides. Subtract from a bundle
83
- before shipping: `bundle(entry).local_files - worker_provides()`."""
84
- provided: set[Path] = set()
85
- for name in _WORKER_BAKED:
86
- origin = _module_file(name)
87
- if origin is not None:
88
- provided |= bundle(origin).local_files
89
- return frozenset(provided)
211
+ def _distribution_index(
212
+ path: tuple[str, ...],
213
+ ) -> dict[Path, importlib.metadata.Distribution]:
214
+ """Every file installed by a distribution found on `path` (sys.path, which
215
+ the cache is keyed on), mapped to that distribution. Each distribution's
216
+ root is resolved once; its files are joined onto it lexically, which keeps
217
+ this fast for environments with tens of thousands of files."""
218
+ index: dict[Path, importlib.metadata.Distribution] = {}
219
+ for dist in importlib.metadata.distributions(path=list(path)):
220
+ root = Path(dist.locate_file("")).resolve()
221
+ for file in dist.files or ():
222
+ index[Path(os.path.normpath(root / file))] = dist
223
+ return index
90
224
 
91
225
 
92
226
  def _is_local(file: Path) -> bool:
@@ -63,6 +63,9 @@ class JobLifecycle:
63
63
  retry: bool = False # static flag set at job creation
64
64
  num_gpus: int = 0
65
65
  num_cpus: int = 1
66
+ # static: pinned third-party requirements the worker pip-installs (the
67
+ # bundle's distributions the Ray image does not already provide)
68
+ pip_requirements: list[str] = field(default_factory=list)
66
69
  history: list[LifecycleEvent] = field(default_factory=list)
67
70
 
68
71
  def to_json(self) -> str:
@@ -229,11 +232,17 @@ def schedule_remote_job(
229
232
  job_id = Haikunator().haikunate(token_length=2, token_chars="0123456789")
230
233
  entry_file = Path(inspect.getfile(fn)).resolve()
231
234
  driver_file = Path(__file__).with_name("_ray_job_driver.py")
232
- files = bundle(entry_file).merge(bundle(driver_file)).local_files - worker_provides()
235
+ desc = bundle(entry_file).merge(bundle(driver_file))
236
+ pip_requirements = desc.pip_requirements(worker_provides())
233
237
  with tempfile.TemporaryDirectory() as tmp:
234
238
  code_root = Path(tmp, "project_code_root")
235
- stage(files, code_root)
236
- log.info("Submitting job %s (%d files)", job_id, len(files))
239
+ stage(desc.local_files, code_root)
240
+ log.info(
241
+ "Submitting job %s (%d files, pip: %s)",
242
+ job_id,
243
+ len(desc.local_files),
244
+ pip_requirements,
245
+ )
237
246
  Payload(
238
247
  experiment_name=experiment_name,
239
248
  run_id=run_id,
@@ -252,6 +261,7 @@ def schedule_remote_job(
252
261
  retry=retry,
253
262
  num_gpus=num_gpus,
254
263
  num_cpus=num_cpus,
264
+ pip_requirements=pip_requirements,
255
265
  ).save_to_mlflow()
256
266
  return job_id
257
267
 
@@ -19,11 +19,12 @@ the end-to-end design.
19
19
  from __future__ import annotations
20
20
 
21
21
  import inspect
22
+ import json
22
23
  import logging
23
24
  import shutil
24
25
  import tempfile
25
26
  import time
26
- from dataclasses import dataclass
27
+ from dataclasses import dataclass, field
27
28
  from pathlib import Path
28
29
  from typing import Any
29
30
 
@@ -90,6 +91,9 @@ class BundleMetadata:
90
91
 
91
92
  bundle_url: str
92
93
  class_import_path: str
94
+ # pinned third-party requirements the replica pip-installs (the bundle's
95
+ # distributions the Ray image does not already provide)
96
+ pip_requirements: list[str] = field(default_factory=list)
93
97
 
94
98
 
95
99
  def bundle_class(
@@ -103,12 +107,18 @@ def bundle_class(
103
107
  without holding the class object."""
104
108
  entry_file = Path(inspect.getfile(cls)).resolve()
105
109
  serve_entry = Path(__file__).with_name("_serve_entry.py")
106
- files = bundle(entry_file).merge(bundle(serve_entry)).local_files - worker_provides()
110
+ desc = bundle(entry_file).merge(bundle(serve_entry))
111
+ pip_requirements = desc.pip_requirements(worker_provides())
107
112
  with tempfile.TemporaryDirectory() as tmp:
108
113
  code_root = Path(tmp) / "code"
109
- stage(files, code_root)
114
+ stage(desc.local_files, code_root)
110
115
  log.info(
111
- "Serve bundle for %s/%s/%s: %d files", family, suffix, run_name, len(files)
116
+ "Serve bundle for %s/%s/%s: %d files, pip: %s",
117
+ family,
118
+ suffix,
119
+ run_name,
120
+ len(desc.local_files),
121
+ pip_requirements,
112
122
  )
113
123
  zip_base = Path(tmp) / f"{family}__{suffix}"
114
124
  shutil.make_archive(str(zip_base), "zip", root_dir=str(code_root))
@@ -119,6 +129,7 @@ def bundle_class(
119
129
  return BundleMetadata(
120
130
  bundle_url=bundle_url,
121
131
  class_import_path=f"{cls.__module__}:{cls.__name__}",
132
+ pip_requirements=pip_requirements,
122
133
  )
123
134
 
124
135
 
@@ -126,6 +137,13 @@ def _build_application_spec(
126
137
  family: str, suffix: str, run_name: str, meta: BundleMetadata
127
138
  ) -> dict[str, Any]:
128
139
  """Assemble a Ray Serve application schema from pre-bundled metadata."""
140
+ # working_dir carries the serve-app's own source; Ray pip-installs the
141
+ # third-party distributions the image lacks into a per-node cached
142
+ # virtualenv layered on the image. No pip key when there are none, so Ray
143
+ # builds no virtualenv.
144
+ runtime_env: dict[str, Any] = {"working_dir": meta.bundle_url}
145
+ if meta.pip_requirements:
146
+ runtime_env["pip"] = meta.pip_requirements
129
147
  return {
130
148
  "name": _app_name(family, suffix, run_name),
131
149
  "route_prefix": _route_prefix(family, suffix, run_name),
@@ -140,9 +158,7 @@ def _build_application_spec(
140
158
  "suffix": suffix,
141
159
  "run_name": run_name,
142
160
  },
143
- # Every dependency ships as source inside the bundle, so working_dir
144
- # alone makes the serve app importable; nothing is pip-installed.
145
- "runtime_env": {"working_dir": meta.bundle_url},
161
+ "runtime_env": runtime_env,
146
162
  }
147
163
 
148
164
 
@@ -150,6 +166,7 @@ def _build_application_spec(
150
166
  # `deploy_model` reads back.
151
167
  _CLASS_IMPORT_PATH_TAG = "class_import_path"
152
168
  _BUNDLE_URL_TAG = "serve_bundle_url"
169
+ _PIP_REQUIREMENTS_TAG = "serve_pip_requirements"
153
170
 
154
171
 
155
172
  def metadata_to_tags(meta: BundleMetadata) -> dict[str, str]:
@@ -159,6 +176,7 @@ def metadata_to_tags(meta: BundleMetadata) -> dict[str, str]:
159
176
  return {
160
177
  _CLASS_IMPORT_PATH_TAG: meta.class_import_path,
161
178
  _BUNDLE_URL_TAG: meta.bundle_url,
179
+ _PIP_REQUIREMENTS_TAG: json.dumps(meta.pip_requirements),
162
180
  }
163
181
 
164
182
 
@@ -180,6 +198,8 @@ def _load_bundle_metadata(
180
198
  return BundleMetadata(
181
199
  bundle_url=tags[_BUNDLE_URL_TAG],
182
200
  class_import_path=tags[_CLASS_IMPORT_PATH_TAG],
201
+ # Absent on models saved before dependencies were pip-installed.
202
+ pip_requirements=json.loads(tags.get(_PIP_REQUIREMENTS_TAG, "[]")),
183
203
  )
184
204
  except KeyError as exc:
185
205
  raise ValueError(
@@ -88,9 +88,10 @@ print(f"Submitted: {job_id}")
88
88
  A separate service — the **jobs control plane** — polls MLflow for pending job requests, matches them against the set of Ray submissions the cluster already has, and submits anything missing. It is also responsible for retrying failed jobs and honouring user-requested stops.
89
89
 
90
90
  Each submission captures the code and dependencies the entry function needs automatically ([_bundle.py](https://github.com/robodatalab/cortexgrid/blob/main/cortexgrid/_bundle.py)):
91
- - `bundle(entry)` traces the import graph from the function's source file, resolving each import the way the interpreter does (via `sys.path`), and returns every file needed to run it -- your own modules and third-party packages alike, wherever they live. The standard library is excluded (it ships with the interpreter)
92
- - Everything ships **as source**: the bundle is staged at each file's import path and tarred into the Ray `working_dir`. Nothing is `pip`-installed on the worker
93
- - Dependencies the worker image already has are subtracted rather than shipped: `bundle(entry) - worker_provides()`, where `worker_provides()` is the bundle of the packages baked into the ray image (torch and its CUDA stack, ray, mlflow, ...). See [k8s/docker/ray/Dockerfile](https://github.com/robodatalab/cortexgrid/blob/main/k8s/docker/ray/Dockerfile)
91
+ - `bundle(entry)` traces the import graph from the function's source file, resolving each import the way the interpreter does (via `sys.path`). The standard library is excluded (it ships with the interpreter)
92
+ - Your own modules -- anything outside site-packages / dist-packages -- ship **as source**: they are staged at their import paths and tarred into the Ray `working_dir`
93
+ - Third-party packages are recorded as the installed distribution that owns the imported file, pinned to its installed version (`tqdm==4.67.3`), and Ray **pip-installs** them on the worker into a per-node cached virtualenv layered on the image (`runtime_env["pip"]`). An import into site-packages that no installed distribution owns fails `cortexgrid.remote` with `UnownedDependencyError`
94
+ - Distributions the worker image already has are not installed again: `worker_provides()` is the dependency closure of the packages baked into the ray image (torch and its CUDA stack, ray, mlflow, ...), and is subtracted from the pip list. See [k8s/docker/ray/Dockerfile](https://github.com/robodatalab/cortexgrid/blob/main/k8s/docker/ray/Dockerfile) and `_WORKER_BAKED` in `_bundle.py`, which must list the same packages
94
95
  - Injects MLflow/S3 credentials so task code running on the DGX can reach all services
95
96
 
96
97
  ##### Retries
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cortexgrid"
3
- version = "0.2.85"
3
+ version = "0.2.86"
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"
@@ -9,6 +9,7 @@ requires-python = ">=3.11"
9
9
  dependencies = [
10
10
  "ray[default]>=2.9,<3",
11
11
  "mlflow>=3.11,<4",
12
+ "packaging>=24",
12
13
  "boto3>=1.34",
13
14
  "setuptools>=82.0.1",
14
15
  "tqdm>=4.60",
File without changes
File without changes