motion-intelligence 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- motion/__init__.py +10 -0
- motion/batch.py +153 -0
- motion/categories.py +46 -0
- motion/cli.py +503 -0
- motion/config.py +148 -0
- motion/dockerfile.py +110 -0
- motion/predictor.py +62 -0
- motion/profile.py +345 -0
- motion/server.py +138 -0
- motion/weight_upload.py +324 -0
- motion_intelligence-0.2.0.dist-info/METADATA +134 -0
- motion_intelligence-0.2.0.dist-info/RECORD +15 -0
- motion_intelligence-0.2.0.dist-info/WHEEL +5 -0
- motion_intelligence-0.2.0.dist-info/entry_points.txt +2 -0
- motion_intelligence-0.2.0.dist-info/top_level.txt +1 -0
motion/dockerfile.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Turn a MotionConfig into a Dockerfile string."""
|
|
2
|
+
from .config import MotionConfig
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def generate_dockerfile(cfg: MotionConfig) -> str:
|
|
6
|
+
b = cfg.build
|
|
7
|
+
py = b.python_version
|
|
8
|
+
|
|
9
|
+
if b.gpu:
|
|
10
|
+
base = f"nvidia/cuda:{b.cuda}.0-cudnn8-runtime-ubuntu22.04"
|
|
11
|
+
# Ubuntu 22.04 ships only python3.10; the deadsnakes PPA provides any
|
|
12
|
+
# other version, then we bootstrap pip for that interpreter via get-pip.
|
|
13
|
+
python_setup = (
|
|
14
|
+
"RUN apt-get update && apt-get install -y --no-install-recommends \\\n"
|
|
15
|
+
" software-properties-common gnupg2 curl ca-certificates \\\n"
|
|
16
|
+
" && add-apt-repository -y ppa:deadsnakes/ppa \\\n"
|
|
17
|
+
" && apt-get update && apt-get install -y --no-install-recommends \\\n"
|
|
18
|
+
f" python{py} python{py}-dev python{py}-distutils \\\n"
|
|
19
|
+
f" && ln -sf /usr/bin/python{py} /usr/bin/python \\\n"
|
|
20
|
+
f" && curl -sS https://bootstrap.pypa.io/get-pip.py | python{py} \\\n"
|
|
21
|
+
" && rm -rf /var/lib/apt/lists/*"
|
|
22
|
+
)
|
|
23
|
+
else:
|
|
24
|
+
base = f"python:{py}-slim"
|
|
25
|
+
python_setup = "# python provided by base image"
|
|
26
|
+
|
|
27
|
+
sys_pkgs = "# (none)"
|
|
28
|
+
if b.system_packages:
|
|
29
|
+
pkgs = " ".join(b.system_packages)
|
|
30
|
+
sys_pkgs = (
|
|
31
|
+
"RUN apt-get update && apt-get install -y --no-install-recommends \\\n"
|
|
32
|
+
f" {pkgs} \\\n"
|
|
33
|
+
" && rm -rf /var/lib/apt/lists/*"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
find_links = " ".join(f"-f {url}" for url in b.python_find_links)
|
|
37
|
+
index_url = f"--index-url {b.python_index_url}" if b.python_index_url else ""
|
|
38
|
+
py_pkgs = "# (none in motion.yml)"
|
|
39
|
+
if b.python_packages:
|
|
40
|
+
pkgs_str = " ".join(f'"{p}"' for p in b.python_packages)
|
|
41
|
+
if b.python_index_url:
|
|
42
|
+
# Three-pass install when a custom index (e.g. PyTorch CPU) is given:
|
|
43
|
+
# 1. Install torch & torchvision from the custom index (CPU-only wheels).
|
|
44
|
+
# 2. Install everything else from PyPI with --upgrade-strategy only-if-needed
|
|
45
|
+
# so pip won't upgrade the already-pinned torch to the GPU variant.
|
|
46
|
+
# 3. Re-pin torch from the custom index again to undo any accidental upgrade
|
|
47
|
+
# that transitive deps might have triggered in step 2.
|
|
48
|
+
torch_pkgs = [p for p in b.python_packages if "torch" in p.lower()]
|
|
49
|
+
other_pkgs = [p for p in b.python_packages if "torch" not in p.lower()]
|
|
50
|
+
lines = []
|
|
51
|
+
if torch_pkgs:
|
|
52
|
+
tp = " ".join(f'"{p}"' for p in torch_pkgs)
|
|
53
|
+
lines.append(f"RUN pip install --no-cache-dir {tp} {index_url}")
|
|
54
|
+
if other_pkgs:
|
|
55
|
+
op = " ".join(f'"{p}"' for p in other_pkgs)
|
|
56
|
+
fl = f" {find_links}" if find_links else ""
|
|
57
|
+
lines.append(
|
|
58
|
+
f"RUN pip install --no-cache-dir --upgrade-strategy only-if-needed {op}{fl}"
|
|
59
|
+
)
|
|
60
|
+
# Re-pin torch so nothing in step 2 silently upgraded it to a GPU wheel
|
|
61
|
+
if torch_pkgs:
|
|
62
|
+
tp = " ".join(f'"{p}"' for p in torch_pkgs)
|
|
63
|
+
lines.append(f"RUN pip install --no-cache-dir --force-reinstall --no-deps {tp} {index_url}")
|
|
64
|
+
py_pkgs = "\n".join(lines)
|
|
65
|
+
else:
|
|
66
|
+
extras = f" {find_links}" if find_links else ""
|
|
67
|
+
py_pkgs = f"RUN pip install --no-cache-dir {pkgs_str}{extras}"
|
|
68
|
+
|
|
69
|
+
# weights are pulled at startup, not baked — only the small manifest is copied
|
|
70
|
+
weights_copy = "# (no weights: section — nothing pulled at startup)"
|
|
71
|
+
if cfg.weights:
|
|
72
|
+
weights_copy = "COPY .motion/weights.json /opt/motion-runtime/weights.json"
|
|
73
|
+
|
|
74
|
+
return f"""# ─── generated by `motion build` — do not edit ───
|
|
75
|
+
FROM {base}
|
|
76
|
+
|
|
77
|
+
ENV PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1 DEBIAN_FRONTEND=noninteractive TZ=UTC
|
|
78
|
+
{python_setup}
|
|
79
|
+
|
|
80
|
+
# --- system packages from motion.yml ---
|
|
81
|
+
{sys_pkgs}
|
|
82
|
+
|
|
83
|
+
# --- the motion runtime (server + contract), vendored into the image ---
|
|
84
|
+
COPY .motion/runtime /opt/motion-runtime
|
|
85
|
+
ENV PYTHONPATH=/opt/motion-runtime
|
|
86
|
+
RUN pip install --no-cache-dir fastapi "uvicorn[standard]" pyyaml python-multipart requests boto3
|
|
87
|
+
|
|
88
|
+
# --- weights manifest (files fetched at startup, see motion.yml `weights:`) ---
|
|
89
|
+
{weights_copy}
|
|
90
|
+
|
|
91
|
+
# --- model python deps from motion.yml ---
|
|
92
|
+
{py_pkgs}
|
|
93
|
+
|
|
94
|
+
WORKDIR /src
|
|
95
|
+
# install requirements.txt first (better layer caching) if present
|
|
96
|
+
COPY requirements.tx[t] /src/
|
|
97
|
+
RUN if [ -f requirements.txt ]; then pip install --no-cache-dir -r requirements.txt; fi
|
|
98
|
+
|
|
99
|
+
# --- the model code ---
|
|
100
|
+
COPY . /src
|
|
101
|
+
|
|
102
|
+
# entrypoint metadata read by motion.server / motion.batch at boot
|
|
103
|
+
ENV MOTION_PREDICT="{cfg.predict}"
|
|
104
|
+
ENV MOTION_NAME="{cfg.name}"
|
|
105
|
+
EXPOSE 8000
|
|
106
|
+
|
|
107
|
+
# Batch mode : JOB_ID is set by the Lambda dispatcher → runs motion.batch (one-shot)
|
|
108
|
+
# HTTP mode : no JOB_ID → runs motion.server (uvicorn)
|
|
109
|
+
CMD ["python", "-c", "import os, runpy; runpy.run_module(\\"motion.batch\\" if os.environ.get(\\"JOB_ID\\") else \\"motion.server\\", run_name=\\"__main__\\")"]
|
|
110
|
+
"""
|
motion/predictor.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""The contract every model's predict.py implements.
|
|
2
|
+
|
|
3
|
+
A model author writes a class that subclasses BasePredictor:
|
|
4
|
+
|
|
5
|
+
from motion import BasePredictor, Input, Path
|
|
6
|
+
|
|
7
|
+
class MyPredictor(BasePredictor):
|
|
8
|
+
def setup(self):
|
|
9
|
+
self.model = load_weights(...) # runs ONCE at container boot
|
|
10
|
+
|
|
11
|
+
def predict(self, video: Path = Input(description="clip"),
|
|
12
|
+
fps: int = Input(default=10)) -> Path:
|
|
13
|
+
... # runs PER request
|
|
14
|
+
return Path("/tmp/out.mp4")
|
|
15
|
+
|
|
16
|
+
`setup()` is separated from `predict()` on purpose: setup is expensive
|
|
17
|
+
(load 5GB of weights into VRAM) and must happen only once, while predict is
|
|
18
|
+
cheap and runs on every request. That split is what makes autoscaling cheap.
|
|
19
|
+
"""
|
|
20
|
+
from pathlib import Path # re-exported so authors do `from motion import Path`
|
|
21
|
+
|
|
22
|
+
# sentinel meaning "no default → this input is required"
|
|
23
|
+
_REQUIRED = object()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Input:
|
|
27
|
+
"""Describes one argument of predict(). Used as the parameter default:
|
|
28
|
+
|
|
29
|
+
fps: int = Input(default=10, description="sampling fps", ge=1, le=60)
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, default=_REQUIRED, description=None, ge=None, le=None,
|
|
33
|
+
choices=None):
|
|
34
|
+
self.default = default
|
|
35
|
+
self.description = description
|
|
36
|
+
self.ge = ge
|
|
37
|
+
self.le = le
|
|
38
|
+
self.choices = choices
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def required(self) -> bool:
|
|
42
|
+
return self.default is _REQUIRED
|
|
43
|
+
|
|
44
|
+
def validate(self, name, value):
|
|
45
|
+
if self.choices is not None and value not in self.choices:
|
|
46
|
+
raise ValueError(f"{name}={value!r} not in allowed {self.choices}")
|
|
47
|
+
if self.ge is not None and value < self.ge:
|
|
48
|
+
raise ValueError(f"{name}={value!r} must be >= {self.ge}")
|
|
49
|
+
if self.le is not None and value > self.le:
|
|
50
|
+
raise ValueError(f"{name}={value!r} must be <= {self.le}")
|
|
51
|
+
return value
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class BasePredictor:
|
|
55
|
+
"""Subclass this in predict.py."""
|
|
56
|
+
|
|
57
|
+
def setup(self) -> None:
|
|
58
|
+
"""Load weights / warm the model. Called once before serving traffic."""
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
def predict(self, **kwargs):
|
|
62
|
+
raise NotImplementedError("Your predictor must implement predict()")
|
motion/profile.py
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""Measure container CPU/memory with docker stats during `motion deploy`."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
import subprocess
|
|
9
|
+
import tempfile
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Callable, Optional
|
|
14
|
+
|
|
15
|
+
MEMORY_HEADROOM = 1.30
|
|
16
|
+
MIN_MEMORY_MIB = 512
|
|
17
|
+
MAX_MEMORY_MIB = 122880
|
|
18
|
+
MIN_CPU_VCPUS = 1
|
|
19
|
+
MAX_CPU_VCPUS = 32
|
|
20
|
+
DEFAULT_TIMEOUT = 120
|
|
21
|
+
|
|
22
|
+
_SAMPLE_SEARCH = [
|
|
23
|
+
"sample.mp4",
|
|
24
|
+
"sample.jpg",
|
|
25
|
+
"sample.png",
|
|
26
|
+
"tests/sample.mp4",
|
|
27
|
+
"tests/sample.jpg",
|
|
28
|
+
"demo_files/input_courthouse.mp4",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def resolve_compute_profile(
|
|
33
|
+
*,
|
|
34
|
+
image: str,
|
|
35
|
+
project_dir: str,
|
|
36
|
+
is_gpu: bool,
|
|
37
|
+
declared_compute: Optional[dict],
|
|
38
|
+
sample_hint: str,
|
|
39
|
+
sample_override: Optional[str],
|
|
40
|
+
no_profile: bool,
|
|
41
|
+
timeout: int,
|
|
42
|
+
log: Callable[[str], None],
|
|
43
|
+
log_warn: Callable[[str], None],
|
|
44
|
+
) -> dict:
|
|
45
|
+
"""Return compute fields for model registration."""
|
|
46
|
+
if declared_compute:
|
|
47
|
+
cpu = declared_compute.get("cpu") or declared_compute.get("cpu_vcpus")
|
|
48
|
+
mem = declared_compute.get("memory") or declared_compute.get("memory_mib")
|
|
49
|
+
gpu = declared_compute.get("gpu", is_gpu)
|
|
50
|
+
log("using declared compute from motion.yml")
|
|
51
|
+
return {
|
|
52
|
+
"cpu_vcpus": int(cpu) if cpu else None,
|
|
53
|
+
"memory_mib": int(mem) if mem else None,
|
|
54
|
+
"gpu": bool(gpu),
|
|
55
|
+
"compute_source": "declared",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if no_profile:
|
|
59
|
+
log_warn("profiling skipped (--no-profile)")
|
|
60
|
+
return {
|
|
61
|
+
"cpu_vcpus": None,
|
|
62
|
+
"memory_mib": None,
|
|
63
|
+
"gpu": is_gpu,
|
|
64
|
+
"compute_source": "declared",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
sample = _find_sample(project_dir, sample_hint, sample_override)
|
|
68
|
+
if not sample:
|
|
69
|
+
log_warn(
|
|
70
|
+
"no sample input for profiling — add `sample:` to motion.yml "
|
|
71
|
+
"or pass --sample. Skipping compute measurement."
|
|
72
|
+
)
|
|
73
|
+
return {
|
|
74
|
+
"cpu_vcpus": None,
|
|
75
|
+
"memory_mib": None,
|
|
76
|
+
"gpu": is_gpu,
|
|
77
|
+
"compute_source": "declared",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
log(f"profiling image with sample {sample} (timeout {timeout}s) …")
|
|
81
|
+
if is_gpu:
|
|
82
|
+
log_warn("GPU model — measuring CPU/RAM only on this machine")
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
raw = _profile_container(image, sample, timeout, is_gpu, log, log_warn)
|
|
86
|
+
except Exception as exc:
|
|
87
|
+
log_warn(f"profiling failed: {exc}")
|
|
88
|
+
return {
|
|
89
|
+
"cpu_vcpus": None,
|
|
90
|
+
"memory_mib": None,
|
|
91
|
+
"gpu": is_gpu,
|
|
92
|
+
"compute_source": "declared",
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if raw.get("error"):
|
|
96
|
+
log_warn(f"profiling error: {raw['error']}")
|
|
97
|
+
return {
|
|
98
|
+
"cpu_vcpus": None,
|
|
99
|
+
"memory_mib": None,
|
|
100
|
+
"gpu": is_gpu,
|
|
101
|
+
"compute_source": "declared",
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
reqs = _derive_requirements(raw, is_gpu)
|
|
105
|
+
if reqs["cpu_vcpus"]:
|
|
106
|
+
log(f"measured cpu_vcpus={reqs['cpu_vcpus']}")
|
|
107
|
+
if reqs["memory_mib"]:
|
|
108
|
+
log(f"measured memory_mib={reqs['memory_mib']}")
|
|
109
|
+
return {**reqs, "compute_source": "measured"}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _find_sample(
|
|
113
|
+
project_dir: str,
|
|
114
|
+
sample_hint: str,
|
|
115
|
+
sample_override: Optional[str],
|
|
116
|
+
) -> Optional[str]:
|
|
117
|
+
if sample_override:
|
|
118
|
+
path = Path(sample_override)
|
|
119
|
+
if not path.is_absolute():
|
|
120
|
+
path = Path(project_dir) / path
|
|
121
|
+
return str(path) if path.is_file() else None
|
|
122
|
+
|
|
123
|
+
if sample_hint:
|
|
124
|
+
path = Path(sample_hint)
|
|
125
|
+
if not path.is_absolute():
|
|
126
|
+
path = Path(project_dir) / path
|
|
127
|
+
if path.is_file():
|
|
128
|
+
return str(path)
|
|
129
|
+
|
|
130
|
+
for rel in _SAMPLE_SEARCH:
|
|
131
|
+
path = Path(project_dir) / rel
|
|
132
|
+
if path.is_file():
|
|
133
|
+
return str(path)
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _run(cmd: list[str], **kwargs) -> subprocess.CompletedProcess:
|
|
138
|
+
return subprocess.run(cmd, capture_output=True, text=True, **kwargs)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _container_running(cid: str) -> bool:
|
|
142
|
+
result = _run(["docker", "inspect", "--format", "{{.State.Running}}", cid])
|
|
143
|
+
return result.stdout.strip() == "true"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _read_cgroup_peak(cid: str) -> Optional[int]:
|
|
147
|
+
result = _run([
|
|
148
|
+
"docker", "exec", cid,
|
|
149
|
+
"sh", "-c",
|
|
150
|
+
"cat /sys/fs/cgroup/memory.peak 2>/dev/null "
|
|
151
|
+
"|| cat /sys/fs/cgroup/memory/memory.max_usage_in_bytes 2>/dev/null",
|
|
152
|
+
])
|
|
153
|
+
try:
|
|
154
|
+
val = int(result.stdout.strip())
|
|
155
|
+
if val > 0:
|
|
156
|
+
return val
|
|
157
|
+
except (ValueError, TypeError):
|
|
158
|
+
pass
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _parse_mem_string(value: str) -> int:
|
|
163
|
+
match = re.match(r"^([\d.]+)\s*([a-zA-Z]*)$", value.strip())
|
|
164
|
+
if not match:
|
|
165
|
+
return 0
|
|
166
|
+
amount, unit = float(match.group(1)), match.group(2).lower()
|
|
167
|
+
multipliers = {
|
|
168
|
+
"b": 1,
|
|
169
|
+
"kb": 1000,
|
|
170
|
+
"kib": 1024,
|
|
171
|
+
"mb": 1_000_000,
|
|
172
|
+
"mib": 1 << 20,
|
|
173
|
+
"gb": 1_000_000_000,
|
|
174
|
+
"gib": 1 << 30,
|
|
175
|
+
}
|
|
176
|
+
return int(amount * multipliers.get(unit, 1))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _docker_stats_sample(cid: str) -> tuple[float, int]:
|
|
180
|
+
result = _run([
|
|
181
|
+
"docker", "stats", "--no-stream", "--format",
|
|
182
|
+
"{{.CPUPerc}}\t{{.MemUsage}}",
|
|
183
|
+
cid,
|
|
184
|
+
])
|
|
185
|
+
cpu_pct = 0.0
|
|
186
|
+
mem_bytes = 0
|
|
187
|
+
if result.returncode != 0:
|
|
188
|
+
return cpu_pct, mem_bytes
|
|
189
|
+
for line in result.stdout.splitlines():
|
|
190
|
+
parts = line.strip().split("\t")
|
|
191
|
+
if len(parts) < 2:
|
|
192
|
+
continue
|
|
193
|
+
try:
|
|
194
|
+
cpu_pct = float(parts[0].rstrip("%"))
|
|
195
|
+
except ValueError:
|
|
196
|
+
pass
|
|
197
|
+
mem_part = parts[1].split("/")[0].strip()
|
|
198
|
+
mem_bytes = _parse_mem_string(mem_part)
|
|
199
|
+
return cpu_pct, mem_bytes
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class _StatsCollector(threading.Thread):
|
|
203
|
+
def __init__(self, cid: str, interval: float = 1.0):
|
|
204
|
+
super().__init__(daemon=True)
|
|
205
|
+
self.cid = cid
|
|
206
|
+
self.interval = interval
|
|
207
|
+
self.cpu_samples: list[float] = []
|
|
208
|
+
self.mem_samples: list[int] = []
|
|
209
|
+
self._stop = threading.Event()
|
|
210
|
+
|
|
211
|
+
def run(self) -> None:
|
|
212
|
+
while not self._stop.is_set():
|
|
213
|
+
cpu, mem = _docker_stats_sample(self.cid)
|
|
214
|
+
if cpu > 0 or mem > 0:
|
|
215
|
+
self.cpu_samples.append(cpu)
|
|
216
|
+
self.mem_samples.append(mem)
|
|
217
|
+
self._stop.wait(self.interval)
|
|
218
|
+
|
|
219
|
+
def stop(self) -> None:
|
|
220
|
+
self._stop.set()
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def avg_cpu_pct(self) -> float:
|
|
224
|
+
return sum(self.cpu_samples) / len(self.cpu_samples) if self.cpu_samples else 0.0
|
|
225
|
+
|
|
226
|
+
@property
|
|
227
|
+
def peak_mem_bytes(self) -> int:
|
|
228
|
+
return max(self.mem_samples, default=0)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _profile_container(
|
|
232
|
+
image: str,
|
|
233
|
+
sample_path: str,
|
|
234
|
+
timeout: int,
|
|
235
|
+
is_gpu: bool,
|
|
236
|
+
log: Callable[[str], None],
|
|
237
|
+
log_warn: Callable[[str], None],
|
|
238
|
+
) -> dict:
|
|
239
|
+
result: dict = {
|
|
240
|
+
"peak_memory_bytes": 0,
|
|
241
|
+
"avg_cpu_pct": 0.0,
|
|
242
|
+
"wall_seconds": 0.0,
|
|
243
|
+
"error": None,
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
host_path = str(Path(sample_path).resolve())
|
|
247
|
+
filename = Path(sample_path).name
|
|
248
|
+
container_input = f"/input/{filename}"
|
|
249
|
+
|
|
250
|
+
cid_file = tempfile.NamedTemporaryFile(delete=False, suffix=".cid")
|
|
251
|
+
cid_path = cid_file.name
|
|
252
|
+
cid_file.close()
|
|
253
|
+
|
|
254
|
+
run_cmd = [
|
|
255
|
+
"docker", "run", "-d",
|
|
256
|
+
"--cidfile", cid_path,
|
|
257
|
+
"--name", f"motion-profile-{int(time.time())}",
|
|
258
|
+
"--platform", "linux/amd64",
|
|
259
|
+
"-v", f"{host_path}:{container_input}:ro",
|
|
260
|
+
image,
|
|
261
|
+
]
|
|
262
|
+
|
|
263
|
+
log(f"docker run (detached) {' '.join(shlex.quote(part) for part in run_cmd)}")
|
|
264
|
+
start = subprocess.Popen(run_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
265
|
+
_, start_err = start.communicate(timeout=30)
|
|
266
|
+
if start.returncode != 0:
|
|
267
|
+
result["error"] = start_err.decode().strip() or "docker run failed"
|
|
268
|
+
return result
|
|
269
|
+
|
|
270
|
+
deadline = time.monotonic() + 10
|
|
271
|
+
cid = ""
|
|
272
|
+
while time.monotonic() < deadline:
|
|
273
|
+
try:
|
|
274
|
+
cid = Path(cid_path).read_text().strip()
|
|
275
|
+
if cid:
|
|
276
|
+
break
|
|
277
|
+
except FileNotFoundError:
|
|
278
|
+
pass
|
|
279
|
+
time.sleep(0.2)
|
|
280
|
+
if not cid:
|
|
281
|
+
result["error"] = "could not read container id"
|
|
282
|
+
return result
|
|
283
|
+
|
|
284
|
+
collector = _StatsCollector(cid, interval=1.0)
|
|
285
|
+
collector.start()
|
|
286
|
+
t0 = time.monotonic()
|
|
287
|
+
waited = 0.0
|
|
288
|
+
while waited < timeout:
|
|
289
|
+
if not _container_running(cid):
|
|
290
|
+
break
|
|
291
|
+
time.sleep(2.0)
|
|
292
|
+
waited += 2.0
|
|
293
|
+
|
|
294
|
+
if waited >= timeout:
|
|
295
|
+
log_warn(f"container exceeded {timeout}s — stopping")
|
|
296
|
+
subprocess.run(["docker", "kill", cid], capture_output=True)
|
|
297
|
+
|
|
298
|
+
cgroup_peak = _read_cgroup_peak(cid)
|
|
299
|
+
collector.stop()
|
|
300
|
+
collector.join(timeout=5)
|
|
301
|
+
|
|
302
|
+
if cgroup_peak and cgroup_peak > 0:
|
|
303
|
+
result["peak_memory_bytes"] = cgroup_peak
|
|
304
|
+
elif collector.peak_mem_bytes > 0:
|
|
305
|
+
result["peak_memory_bytes"] = collector.peak_mem_bytes
|
|
306
|
+
|
|
307
|
+
result["avg_cpu_pct"] = round(collector.avg_cpu_pct, 1)
|
|
308
|
+
result["wall_seconds"] = round(time.monotonic() - t0, 1)
|
|
309
|
+
subprocess.run(["docker", "wait", cid], capture_output=True, timeout=15)
|
|
310
|
+
try:
|
|
311
|
+
os.unlink(cid_path)
|
|
312
|
+
except OSError:
|
|
313
|
+
pass
|
|
314
|
+
|
|
315
|
+
if is_gpu:
|
|
316
|
+
# Trust motion.yml on dev machines without CUDA.
|
|
317
|
+
pass
|
|
318
|
+
|
|
319
|
+
return result
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _derive_requirements(raw: dict, is_gpu: bool) -> dict:
|
|
323
|
+
peak_bytes = raw.get("peak_memory_bytes", 0)
|
|
324
|
+
avg_cpu_pct = raw.get("avg_cpu_pct", 0.0)
|
|
325
|
+
|
|
326
|
+
if peak_bytes > 0:
|
|
327
|
+
peak_mib = peak_bytes / (1 << 20)
|
|
328
|
+
memory_mib = math.ceil(peak_mib * MEMORY_HEADROOM)
|
|
329
|
+
memory_mib = max(MIN_MEMORY_MIB, min(MAX_MEMORY_MIB, memory_mib))
|
|
330
|
+
else:
|
|
331
|
+
memory_mib = None
|
|
332
|
+
|
|
333
|
+
if avg_cpu_pct > 0:
|
|
334
|
+
cpu_vcpus = max(
|
|
335
|
+
MIN_CPU_VCPUS,
|
|
336
|
+
min(MAX_CPU_VCPUS, math.ceil(avg_cpu_pct / 100.0)),
|
|
337
|
+
)
|
|
338
|
+
else:
|
|
339
|
+
cpu_vcpus = None
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
"cpu_vcpus": cpu_vcpus,
|
|
343
|
+
"memory_mib": memory_mib,
|
|
344
|
+
"gpu": is_gpu,
|
|
345
|
+
}
|
motion/server.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Uniform HTTP server baked into every model image.
|
|
2
|
+
|
|
3
|
+
At boot it reads MOTION_PREDICT ("predict.py:ClassName"), imports the user's
|
|
4
|
+
predictor, runs setup() once, then serves /predict per request. Identical for
|
|
5
|
+
every model — that's the whole point of the packaging contract.
|
|
6
|
+
|
|
7
|
+
Run with: python -m motion.server (this is the container CMD)
|
|
8
|
+
"""
|
|
9
|
+
import importlib.util
|
|
10
|
+
import inspect
|
|
11
|
+
import os
|
|
12
|
+
import tempfile
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .predictor import BasePredictor, Input
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _load_predictor() -> BasePredictor:
|
|
19
|
+
spec_str = os.environ.get("MOTION_PREDICT")
|
|
20
|
+
if not spec_str or ":" not in spec_str:
|
|
21
|
+
raise SystemExit("MOTION_PREDICT must be set to 'file.py:ClassName'")
|
|
22
|
+
file_part, class_name = spec_str.split(":", 1)
|
|
23
|
+
file_path = os.path.join("/src", file_part)
|
|
24
|
+
|
|
25
|
+
spec = importlib.util.spec_from_file_location("motion_user_predict", file_path)
|
|
26
|
+
module = importlib.util.module_from_spec(spec)
|
|
27
|
+
spec.loader.exec_module(module)
|
|
28
|
+
klass = getattr(module, class_name)
|
|
29
|
+
return klass()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _coerce(param, raw_value):
|
|
33
|
+
"""Download Path inputs; validate via the Input descriptor."""
|
|
34
|
+
meta = param.default if isinstance(param.default, Input) else None
|
|
35
|
+
value = raw_value
|
|
36
|
+
if param.annotation is Path or (meta and meta.description and "path" in str(param.annotation)):
|
|
37
|
+
# input is a file → fetch URL to a local temp path
|
|
38
|
+
import requests
|
|
39
|
+
suffix = os.path.splitext(str(raw_value).split("?")[0])[1] or ""
|
|
40
|
+
fd, local = tempfile.mkstemp(suffix=suffix)
|
|
41
|
+
os.close(fd)
|
|
42
|
+
with requests.get(raw_value, stream=True) as r:
|
|
43
|
+
r.raise_for_status()
|
|
44
|
+
with open(local, "wb") as f:
|
|
45
|
+
for chunk in r.iter_content(chunk_size=1 << 20):
|
|
46
|
+
f.write(chunk)
|
|
47
|
+
value = Path(local)
|
|
48
|
+
if meta:
|
|
49
|
+
value = meta.validate(param.name, value)
|
|
50
|
+
return value
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _fetch_weights():
|
|
54
|
+
"""Download weights declared in motion.yml `weights:` before setup().
|
|
55
|
+
|
|
56
|
+
Kept out of the image to avoid bloat / slow cold starts; pulled here at boot
|
|
57
|
+
into /src/<dest> (skipped if already present, e.g. a mounted volume).
|
|
58
|
+
"""
|
|
59
|
+
manifest = "/opt/motion-runtime/weights.json"
|
|
60
|
+
if not os.path.exists(manifest):
|
|
61
|
+
return
|
|
62
|
+
import json
|
|
63
|
+
with open(manifest) as f:
|
|
64
|
+
items = json.load(f)
|
|
65
|
+
for w in items:
|
|
66
|
+
dest = os.path.join("/src", w["dest"])
|
|
67
|
+
if os.path.exists(dest):
|
|
68
|
+
print(f"[motion] weight present, skipping: {w['dest']}")
|
|
69
|
+
continue
|
|
70
|
+
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
|
71
|
+
url = w["url"]
|
|
72
|
+
print(f"[motion] fetching weight {url} -> {dest}")
|
|
73
|
+
if url.startswith("s3://"):
|
|
74
|
+
import boto3
|
|
75
|
+
bucket, key = url[5:].split("/", 1)
|
|
76
|
+
boto3.client("s3").download_file(bucket, key, dest)
|
|
77
|
+
else:
|
|
78
|
+
import requests
|
|
79
|
+
with requests.get(url, stream=True) as r:
|
|
80
|
+
r.raise_for_status()
|
|
81
|
+
with open(dest, "wb") as out:
|
|
82
|
+
for chunk in r.iter_content(chunk_size=1 << 20):
|
|
83
|
+
out.write(chunk)
|
|
84
|
+
print("[motion] weights ready")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def build_app():
|
|
88
|
+
from fastapi import FastAPI, HTTPException
|
|
89
|
+
|
|
90
|
+
_fetch_weights() # ← pull declared weights before the model loads
|
|
91
|
+
predictor = _load_predictor()
|
|
92
|
+
predictor.setup() # ← runs exactly once, before any request
|
|
93
|
+
sig = inspect.signature(predictor.predict)
|
|
94
|
+
|
|
95
|
+
app = FastAPI(title=os.environ.get("MOTION_NAME", "motion-model"))
|
|
96
|
+
|
|
97
|
+
@app.get("/health")
|
|
98
|
+
def health():
|
|
99
|
+
return {"status": "ok", "model": os.environ.get("MOTION_NAME")}
|
|
100
|
+
|
|
101
|
+
@app.get("/schema")
|
|
102
|
+
def schema():
|
|
103
|
+
out = {}
|
|
104
|
+
for name, p in sig.parameters.items():
|
|
105
|
+
m = p.default if isinstance(p.default, Input) else None
|
|
106
|
+
out[name] = {
|
|
107
|
+
"type": getattr(p.annotation, "__name__", str(p.annotation)),
|
|
108
|
+
"required": m.required if m else p.default is inspect._empty,
|
|
109
|
+
"default": None if (m is None or m.required) else m.default,
|
|
110
|
+
"description": m.description if m else None,
|
|
111
|
+
}
|
|
112
|
+
return out
|
|
113
|
+
|
|
114
|
+
@app.post("/predict")
|
|
115
|
+
def predict(payload: dict):
|
|
116
|
+
given = payload.get("input", payload) or {}
|
|
117
|
+
kwargs = {}
|
|
118
|
+
for name, p in sig.parameters.items():
|
|
119
|
+
m = p.default if isinstance(p.default, Input) else None
|
|
120
|
+
if name in given:
|
|
121
|
+
kwargs[name] = _coerce(p, given[name])
|
|
122
|
+
elif m and not m.required:
|
|
123
|
+
kwargs[name] = m.default
|
|
124
|
+
elif m and m.required:
|
|
125
|
+
raise HTTPException(400, f"missing required input '{name}'")
|
|
126
|
+
try:
|
|
127
|
+
result = predictor.predict(**kwargs)
|
|
128
|
+
except Exception as e:
|
|
129
|
+
raise HTTPException(500, f"prediction failed: {e}")
|
|
130
|
+
# Phase 0: return local path. Phase 'deploy' will upload to S3 here.
|
|
131
|
+
return {"output": str(result)}
|
|
132
|
+
|
|
133
|
+
return app
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
if __name__ == "__main__":
|
|
137
|
+
import uvicorn
|
|
138
|
+
uvicorn.run(build_app(), host="0.0.0.0", port=8000)
|