ei-pipe-sdk 0.1.5__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.
- ei_pipe_sdk-0.1.5/PKG-INFO +97 -0
- ei_pipe_sdk-0.1.5/README.md +88 -0
- ei_pipe_sdk-0.1.5/pyproject.toml +48 -0
- ei_pipe_sdk-0.1.5/setup.cfg +4 -0
- ei_pipe_sdk-0.1.5/src/ei_infer/__init__.py +16 -0
- ei_pipe_sdk-0.1.5/src/ei_infer/env.py +80 -0
- ei_pipe_sdk-0.1.5/src/ei_infer/model.py +46 -0
- ei_pipe_sdk-0.1.5/src/ei_infer/registry.py +75 -0
- ei_pipe_sdk-0.1.5/src/ei_infer/task.py +124 -0
- ei_pipe_sdk-0.1.5/src/ei_infer/worker.py +78 -0
- ei_pipe_sdk-0.1.5/src/ei_path/__init__.py +1 -0
- ei_pipe_sdk-0.1.5/src/ei_path/paths.py +93 -0
- ei_pipe_sdk-0.1.5/src/ei_pipe_sdk.egg-info/PKG-INFO +97 -0
- ei_pipe_sdk-0.1.5/src/ei_pipe_sdk.egg-info/SOURCES.txt +18 -0
- ei_pipe_sdk-0.1.5/src/ei_pipe_sdk.egg-info/dependency_links.txt +1 -0
- ei_pipe_sdk-0.1.5/src/ei_pipe_sdk.egg-info/requires.txt +2 -0
- ei_pipe_sdk-0.1.5/src/ei_pipe_sdk.egg-info/top_level.txt +2 -0
- ei_pipe_sdk-0.1.5/tests/test_registry.py +93 -0
- ei_pipe_sdk-0.1.5/tests/test_task.py +299 -0
- ei_pipe_sdk-0.1.5/tests/test_worker.py +166 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ei-pipe-sdk
|
|
3
|
+
Version: 0.1.5
|
|
4
|
+
Summary: Minimal inference SDK for algorithm engineers on the ei-pipeline ei_infer node
|
|
5
|
+
Requires-Python: <3.15,>=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: arq>=0.26
|
|
8
|
+
Requires-Dist: redis>=8.0.1
|
|
9
|
+
|
|
10
|
+
# ei-pipe-sdk
|
|
11
|
+
|
|
12
|
+
面向算法同学的极简模型接入 SDK。把你的模型写成一个 `EiInfer` 的两个方法,ei-pipeline
|
|
13
|
+
就能调度它推理:**你不用关心数据怎么进来、结果怎么提交,也不用关心部署**。
|
|
14
|
+
|
|
15
|
+
## 安装
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install ei-pipe-sdk
|
|
19
|
+
# 或先在仓库 ei_pipe_sdk/ 下本地构建
|
|
20
|
+
uv build && pip install dist/ei_pipe_sdk-*.whl
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## 三步接入
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
# my_model.py
|
|
27
|
+
from ei_infer import EiInfer, register
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class MyModel(EiInfer):
|
|
31
|
+
def init(self, model_cfg: dict | None = None) -> None:
|
|
32
|
+
# 进程启动时调用一次:加载权重、tokenizer、选设备。失败会让 worker 启动即崩溃。
|
|
33
|
+
self.model = load_weights(...)
|
|
34
|
+
|
|
35
|
+
def inference(self, input_path: str, output_path: str, node_info: dict) -> dict:
|
|
36
|
+
# input_path / output_path: worker 已恢复好的绝对路径
|
|
37
|
+
# = {EI_INFER_DATA_ROOT}/{time_bucket}/{pipeline_id}/<相对路径>
|
|
38
|
+
# input_path 是输入(文件或目录);output_path 是允许写结果文件的目录(可能为空)。
|
|
39
|
+
# 是否落盘由模型自己决定;返回的 dict 会被 worker 回传给 pipeline。
|
|
40
|
+
return {"detections": self.model(open(input_path))}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
register("my-model", MyModel()) # 注册名 = 管道节点 params.name 的值
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install ei-pipe-sdk
|
|
48
|
+
# 在算法部署里跑(pod 内需挂载 PFS data_root 到 EI_INFER_DATA_ROOT)
|
|
49
|
+
EI_INFER_DATA_ROOT=/lpai/pvc/ei-autolabel-bd-ga-infer python -m ei_infer.worker
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## 协议
|
|
53
|
+
|
|
54
|
+
调度端(ei-pipeline 的 `ei_infer` 节点)会按统一协议给你发任务:
|
|
55
|
+
|
|
56
|
+
| 字段 | 类型 | 说明 |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| `name` | str | 模型注册名,由你 `register("name", ...)` 时命名 |
|
|
59
|
+
| `input_path` | str | 输入路径(相对 pipe 共享目录,如 `data/`),worker 恢复为绝对路径 |
|
|
60
|
+
| `output_path` | str? | 允许写结果文件的目录(相对 pipe 共享目录,可选) |
|
|
61
|
+
| `version` | str? | 版本提示,透传保留 |
|
|
62
|
+
|
|
63
|
+
worker 用 `node_info.time_bucket + pipeline_id + EI_INFER_DATA_ROOT` 把 `input_path` /
|
|
64
|
+
`output_path` 恢复成绝对路径,调用 `inference(input_path, output_path, node_info)`。
|
|
65
|
+
**是否把结果落盘由模型自己决定**,worker 不替模型落盘,只把模型返回的 dict 原样回传。
|
|
66
|
+
整个链路里**你的函数只看绝对路径 input_path / output_path 和 node_info**,其余(数据就位、
|
|
67
|
+
并发、轮询)全部由 SDK 兜底。
|
|
68
|
+
|
|
69
|
+
## 配置(环境变量)
|
|
70
|
+
|
|
71
|
+
| 变量 | 默认 | 说明 |
|
|
72
|
+
|---|---|---|
|
|
73
|
+
| `EI_INFER_REDIS_URL` | 回退 `EI_ARQ_REDIS_URL` → `EI_REDIS_URL` → `redis://localhost:6379/0` | 消费队列的 Redis |
|
|
74
|
+
| `EI_INFER_QUEUE_NAME` | `arq:{env}:ei:model-infer` | 消费的任务队列,默认与 ei-pipeline `ei_infer` 节点指向一致 |
|
|
75
|
+
| `EI_INFER_MAX_JOBS` | `4` | 每 worker 并发任务数(arq `max_jobs`) |
|
|
76
|
+
| `EI_INFER_MAX_THREADS` | `max(4, max_jobs)` | 跑同步推理的线程池大小 |
|
|
77
|
+
| `EI_INFER_DATA_ROOT` | `/lpai/pvc/ei-autolabel-bd-ga-infer` | PFS data_root 在 pod 内的挂载根 |
|
|
78
|
+
|
|
79
|
+
## 多模型
|
|
80
|
+
|
|
81
|
+
多个模型可在同一个 worker 进程里 `register` 多个名字;每个模型各自实现自己的一套动作。
|
|
82
|
+
一个队列可由多个模型 worker 消费,任务按 `name` 路由到注册了同名模型的进程。
|
|
83
|
+
|
|
84
|
+
## 失败模式
|
|
85
|
+
|
|
86
|
+
- `inference` 返回非 dict → 任务失败(错误信息含模型名)。
|
|
87
|
+
- `name` 未注册 → 任务失败,节点错误信息会指出未知模型名。
|
|
88
|
+
- `init` 抛异常 → worker 启动即退出,方便在你自己的日志里早发现。
|
|
89
|
+
|
|
90
|
+
## 本地联调
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
# 起一个本地 redis 后(redis-server)
|
|
94
|
+
python -m ei_infer.worker # 默认队列 arq:local:ei:model-infer
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
然后在 ei-pipeline 用 `ei_infer` 节点提交一条任务即可走通。
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# ei-pipe-sdk
|
|
2
|
+
|
|
3
|
+
面向算法同学的极简模型接入 SDK。把你的模型写成一个 `EiInfer` 的两个方法,ei-pipeline
|
|
4
|
+
就能调度它推理:**你不用关心数据怎么进来、结果怎么提交,也不用关心部署**。
|
|
5
|
+
|
|
6
|
+
## 安装
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install ei-pipe-sdk
|
|
10
|
+
# 或先在仓库 ei_pipe_sdk/ 下本地构建
|
|
11
|
+
uv build && pip install dist/ei_pipe_sdk-*.whl
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## 三步接入
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
# my_model.py
|
|
18
|
+
from ei_infer import EiInfer, register
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MyModel(EiInfer):
|
|
22
|
+
def init(self, model_cfg: dict | None = None) -> None:
|
|
23
|
+
# 进程启动时调用一次:加载权重、tokenizer、选设备。失败会让 worker 启动即崩溃。
|
|
24
|
+
self.model = load_weights(...)
|
|
25
|
+
|
|
26
|
+
def inference(self, input_path: str, output_path: str, node_info: dict) -> dict:
|
|
27
|
+
# input_path / output_path: worker 已恢复好的绝对路径
|
|
28
|
+
# = {EI_INFER_DATA_ROOT}/{time_bucket}/{pipeline_id}/<相对路径>
|
|
29
|
+
# input_path 是输入(文件或目录);output_path 是允许写结果文件的目录(可能为空)。
|
|
30
|
+
# 是否落盘由模型自己决定;返回的 dict 会被 worker 回传给 pipeline。
|
|
31
|
+
return {"detections": self.model(open(input_path))}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
register("my-model", MyModel()) # 注册名 = 管道节点 params.name 的值
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install ei-pipe-sdk
|
|
39
|
+
# 在算法部署里跑(pod 内需挂载 PFS data_root 到 EI_INFER_DATA_ROOT)
|
|
40
|
+
EI_INFER_DATA_ROOT=/lpai/pvc/ei-autolabel-bd-ga-infer python -m ei_infer.worker
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## 协议
|
|
44
|
+
|
|
45
|
+
调度端(ei-pipeline 的 `ei_infer` 节点)会按统一协议给你发任务:
|
|
46
|
+
|
|
47
|
+
| 字段 | 类型 | 说明 |
|
|
48
|
+
|---|---|---|
|
|
49
|
+
| `name` | str | 模型注册名,由你 `register("name", ...)` 时命名 |
|
|
50
|
+
| `input_path` | str | 输入路径(相对 pipe 共享目录,如 `data/`),worker 恢复为绝对路径 |
|
|
51
|
+
| `output_path` | str? | 允许写结果文件的目录(相对 pipe 共享目录,可选) |
|
|
52
|
+
| `version` | str? | 版本提示,透传保留 |
|
|
53
|
+
|
|
54
|
+
worker 用 `node_info.time_bucket + pipeline_id + EI_INFER_DATA_ROOT` 把 `input_path` /
|
|
55
|
+
`output_path` 恢复成绝对路径,调用 `inference(input_path, output_path, node_info)`。
|
|
56
|
+
**是否把结果落盘由模型自己决定**,worker 不替模型落盘,只把模型返回的 dict 原样回传。
|
|
57
|
+
整个链路里**你的函数只看绝对路径 input_path / output_path 和 node_info**,其余(数据就位、
|
|
58
|
+
并发、轮询)全部由 SDK 兜底。
|
|
59
|
+
|
|
60
|
+
## 配置(环境变量)
|
|
61
|
+
|
|
62
|
+
| 变量 | 默认 | 说明 |
|
|
63
|
+
|---|---|---|
|
|
64
|
+
| `EI_INFER_REDIS_URL` | 回退 `EI_ARQ_REDIS_URL` → `EI_REDIS_URL` → `redis://localhost:6379/0` | 消费队列的 Redis |
|
|
65
|
+
| `EI_INFER_QUEUE_NAME` | `arq:{env}:ei:model-infer` | 消费的任务队列,默认与 ei-pipeline `ei_infer` 节点指向一致 |
|
|
66
|
+
| `EI_INFER_MAX_JOBS` | `4` | 每 worker 并发任务数(arq `max_jobs`) |
|
|
67
|
+
| `EI_INFER_MAX_THREADS` | `max(4, max_jobs)` | 跑同步推理的线程池大小 |
|
|
68
|
+
| `EI_INFER_DATA_ROOT` | `/lpai/pvc/ei-autolabel-bd-ga-infer` | PFS data_root 在 pod 内的挂载根 |
|
|
69
|
+
|
|
70
|
+
## 多模型
|
|
71
|
+
|
|
72
|
+
多个模型可在同一个 worker 进程里 `register` 多个名字;每个模型各自实现自己的一套动作。
|
|
73
|
+
一个队列可由多个模型 worker 消费,任务按 `name` 路由到注册了同名模型的进程。
|
|
74
|
+
|
|
75
|
+
## 失败模式
|
|
76
|
+
|
|
77
|
+
- `inference` 返回非 dict → 任务失败(错误信息含模型名)。
|
|
78
|
+
- `name` 未注册 → 任务失败,节点错误信息会指出未知模型名。
|
|
79
|
+
- `init` 抛异常 → worker 启动即退出,方便在你自己的日志里早发现。
|
|
80
|
+
|
|
81
|
+
## 本地联调
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
# 起一个本地 redis 后(redis-server)
|
|
85
|
+
python -m ei_infer.worker # 默认队列 arq:local:ei:model-infer
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
然后在 ei-pipeline 用 `ei_infer` 节点提交一条任务即可走通。
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ei-pipe-sdk"
|
|
3
|
+
version = "0.1.5"
|
|
4
|
+
description = "Minimal inference SDK for algorithm engineers on the ei-pipeline ei_infer node"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11,<3.15"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"arq>=0.26",
|
|
9
|
+
# arq caps redis<6, but it is runtime-compatible with redis-py 8; aligned with
|
|
10
|
+
# the ei-pipeline override so both projects share one Redis client version.
|
|
11
|
+
"redis>=8.0.1",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[build-system]
|
|
15
|
+
requires = ["setuptools>=61.0"]
|
|
16
|
+
build-backend = "setuptools.build_meta"
|
|
17
|
+
|
|
18
|
+
[tool.hatch.build.targets.wheel]
|
|
19
|
+
packages = ["src/ei_infer", "src/ei_path"]
|
|
20
|
+
|
|
21
|
+
[tool.pytest.ini_options]
|
|
22
|
+
testpaths = ["tests"]
|
|
23
|
+
python_files = ["test_*.py"]
|
|
24
|
+
addopts = "--strict-markers --strict-config"
|
|
25
|
+
asyncio_mode = "auto"
|
|
26
|
+
pythonpath = [".", "src"]
|
|
27
|
+
|
|
28
|
+
[dependency-groups]
|
|
29
|
+
dev = [
|
|
30
|
+
"pytest>=8.4.1",
|
|
31
|
+
"pytest-asyncio>=1.1.0",
|
|
32
|
+
"fakeredis[lua]>=2.37.0",
|
|
33
|
+
"pytest-xdist>=3.8.0",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[[tool.uv.index]]
|
|
37
|
+
name = "liauto-pypi-l5"
|
|
38
|
+
url = "https://artifactory.ep.chehejia.com/artifactory/api/pypi/liauto-pypi-l5/simple"
|
|
39
|
+
default = true
|
|
40
|
+
|
|
41
|
+
[tool.uv]
|
|
42
|
+
# Use only the system CPython; never download a managed (uv-managed) interpreter.
|
|
43
|
+
# The pipeline pod/ci image provides python 3.11+ itself, so this avoids hitting
|
|
44
|
+
# the network-to-fetch-CPython when the image lacks a matching interpreter.
|
|
45
|
+
python-preference = "only-system"
|
|
46
|
+
# arq caps redis<6, but it is runtime-compatible with redis-py 8; override so
|
|
47
|
+
# arq can coexist with redis>=8.0.1 (mirrors the ei-pipeline override).
|
|
48
|
+
override-dependencies = ["redis>=8.0.1"]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""ei-infer: minimal inference SDK for algorithm models on ei-pipeline.
|
|
2
|
+
|
|
3
|
+
Public surface for an algorithm engineer:
|
|
4
|
+
|
|
5
|
+
- ``EiInfer`` base class to implement (``init`` + ``inference``)
|
|
6
|
+
- ``EI_REGISTER`` decorator to put a model into the in-process registry
|
|
7
|
+
|
|
8
|
+
Everything else (registry internals, task runner, worker entry) lives in
|
|
9
|
+
submodules and is not re-exported here on purpose.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .model import EiInfer
|
|
13
|
+
from .registry import EI_REGISTER
|
|
14
|
+
from .worker import run_loop
|
|
15
|
+
|
|
16
|
+
__all__ = ["EiInfer", "EI_REGISTER", "run_loop"]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""SDK environment helpers: EI_INFER_* with fallback to the shared Redis vars."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from urllib.parse import unquote, urlparse
|
|
5
|
+
|
|
6
|
+
from arq.connections import RedisSettings
|
|
7
|
+
|
|
8
|
+
DEFAULT_DATA_ROOT = "/lpai/pvc/ei-autolabel-bd-ga-infer"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def resolve_env() -> str:
|
|
12
|
+
"""Resolve active environment, mirroring ei-pipeline's envget_core_env."""
|
|
13
|
+
return os.getenv("EI_ENV") or os.getenv("LI_ENV") or "local"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def infer_redis_url() -> str:
|
|
17
|
+
"""Redis the worker consumes from.
|
|
18
|
+
|
|
19
|
+
EI_INFER_REDIS_URL overrides; otherwise fall back to EI_ARQ_REDIS_URL, then
|
|
20
|
+
EI_REDIS_URL, then a local dev default. Allows a zero-config local run and
|
|
21
|
+
reuse of the ei-pipeline cluster in production.
|
|
22
|
+
"""
|
|
23
|
+
return (
|
|
24
|
+
os.getenv("EI_INFER_REDIS_URL")
|
|
25
|
+
or os.getenv("EI_ARQ_REDIS_URL")
|
|
26
|
+
or os.getenv("EI_REDIS_URL")
|
|
27
|
+
or "redis://localhost:6379/0"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def redis_settings_from_url(url: str) -> RedisSettings:
|
|
32
|
+
"""Parse a Redis URL into RedisSettings, decoding percent-encoded auth.
|
|
33
|
+
|
|
34
|
+
arq's ``RedisSettings.from_dsn`` forwards ``urlparse``'s raw username and
|
|
35
|
+
password through to the AUTH command, so a URL like
|
|
36
|
+
``redis://user:p%40ss@host`` would authenticate with the literal bytes
|
|
37
|
+
``p%40ss`` instead of ``p@ss``. Decode both fields with ``unquote`` (not
|
|
38
|
+
``unquote_plus``) so a literal ``+`` in a password is preserved.
|
|
39
|
+
"""
|
|
40
|
+
settings = RedisSettings.from_dsn(url)
|
|
41
|
+
parsed = urlparse(url)
|
|
42
|
+
if parsed.username is not None:
|
|
43
|
+
settings.username = unquote(parsed.username)
|
|
44
|
+
if parsed.password is not None:
|
|
45
|
+
settings.password = unquote(parsed.password)
|
|
46
|
+
return settings
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def infer_queue_name() -> str:
|
|
50
|
+
"""Task queue this worker consumes.
|
|
51
|
+
|
|
52
|
+
Default matches the ei-pipeline ei_infer node's build_default_queue_name()
|
|
53
|
+
(env segment hashed when > 32 chars), so a zero-config pipe and worker land
|
|
54
|
+
on the same queue.
|
|
55
|
+
"""
|
|
56
|
+
return os.getenv("EI_INFER_QUEUE_NAME") or f"arq:{resolve_env()}:ei:model-infer"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def infer_max_jobs() -> int:
|
|
60
|
+
"""Concurrent jobs per worker (arq max_jobs). Default: 4. Must be positive."""
|
|
61
|
+
value = int(os.getenv("EI_INFER_MAX_JOBS") or "4")
|
|
62
|
+
if value <= 0:
|
|
63
|
+
raise ValueError("EI_INFER_MAX_JOBS must be positive")
|
|
64
|
+
return value
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def infer_max_threads() -> int:
|
|
68
|
+
"""Thread pool size for sync inference, at least one per concurrent job."""
|
|
69
|
+
return max(4, infer_max_jobs())
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def infer_data_root() -> str:
|
|
73
|
+
"""PFS data_root mount point inside the pod.
|
|
74
|
+
|
|
75
|
+
The pipeline sends an input_path relative to this mount, and the worker
|
|
76
|
+
forwards it unchanged to ``model.inference(input_path, node_info)``; the
|
|
77
|
+
algorithm joins this root to read the input file. Default matches the
|
|
78
|
+
ei-pipeline PFS data root.
|
|
79
|
+
"""
|
|
80
|
+
return os.getenv("EI_INFER_DATA_ROOT") or DEFAULT_DATA_ROOT
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Algorithm model abstraction: implement exactly two methods to join the pipeline."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class EiInfer(ABC):
|
|
7
|
+
"""What an algorithm engineer implements to plug a model into ei-pipeline.
|
|
8
|
+
|
|
9
|
+
Convention
|
|
10
|
+
----------
|
|
11
|
+
- ``init`` runs once per worker process (model warm-up). Fail fast here:
|
|
12
|
+
a model that cannot initialize must crash the worker on boot, not the
|
|
13
|
+
first job. The current worker calls ``init()`` with no arguments;
|
|
14
|
+
``model_cfg`` is reserved for a future config loader that would pass a
|
|
15
|
+
parsed dict in.
|
|
16
|
+
- ``inference`` receives the absolute input path, an optional absolute
|
|
17
|
+
output directory, and the caller's ``node_info``; it **returns a dict**.
|
|
18
|
+
The worker returns that dict to the pipeline and does NOT persist it -
|
|
19
|
+
the model decides whether to write result files into ``output_path``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def init(self, model_cfg: dict | None = None) -> None:
|
|
24
|
+
"""Load model artifacts (weights, tokenizer, device).
|
|
25
|
+
|
|
26
|
+
Called once per worker process before the first inference. ``model_cfg``
|
|
27
|
+
is reserved for a future caller that would pass parsed config (e.g. a
|
|
28
|
+
yaml); today the worker calls ``init()`` with no arguments, so
|
|
29
|
+
implementations that need per-model config should read it themselves
|
|
30
|
+
(env var, local file, etc.). Raise to abort worker startup.
|
|
31
|
+
"""
|
|
32
|
+
raise NotImplementedError
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def inference(self, input_path: str, output_path: str, node_info: dict) -> dict:
|
|
36
|
+
"""Run one inference and return the result dict.
|
|
37
|
+
|
|
38
|
+
``input_path`` is the absolute path of the input the pipeline staged
|
|
39
|
+
(a file or a directory of material). ``output_path`` is the absolute
|
|
40
|
+
directory (may be empty) where the model may persist result files when
|
|
41
|
+
it chooses to; whether to write is the model's decision. ``node_info``
|
|
42
|
+
carries caller context (pipeline_id, node_name, params, ...), may be an
|
|
43
|
+
empty dict but is always provided. The returned dict must be
|
|
44
|
+
JSON-serializable; it becomes the pipeline node's result.
|
|
45
|
+
"""
|
|
46
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Process-local, thread-safe model registry: name -> EiInfer instance."""
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import TypeVar
|
|
6
|
+
|
|
7
|
+
from .model import EiInfer
|
|
8
|
+
|
|
9
|
+
T = TypeVar("T", bound=EiInfer)
|
|
10
|
+
|
|
11
|
+
_lock = threading.RLock()
|
|
12
|
+
_registry: dict[str, EiInfer] = {}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _register_instance(name: str, model: EiInfer) -> None:
|
|
16
|
+
"""Register ``model`` under ``name`` (internal, used by the decorator).
|
|
17
|
+
|
|
18
|
+
Re-registering the *same* instance under the same name is idempotent;
|
|
19
|
+
registering a *different* instance under an existing name raises, so a
|
|
20
|
+
running worker can never silently swap a model mid-flight.
|
|
21
|
+
"""
|
|
22
|
+
if not isinstance(name, str) or not name.strip():
|
|
23
|
+
raise ValueError("model name must be a non-empty string")
|
|
24
|
+
if not isinstance(model, EiInfer):
|
|
25
|
+
raise TypeError("model must be an EiInfer instance")
|
|
26
|
+
with _lock:
|
|
27
|
+
existing = _registry.get(name)
|
|
28
|
+
if existing is not None and existing is not model:
|
|
29
|
+
raise ValueError(f"model '{name}' already registered")
|
|
30
|
+
_registry[name] = model
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def EI_REGISTER(model_name: str) -> Callable[[type[T]], type[T]]:
|
|
34
|
+
"""Register an EiInfer subclass as a decorator.
|
|
35
|
+
|
|
36
|
+
Usage::
|
|
37
|
+
|
|
38
|
+
@EI_REGISTER("example-model")
|
|
39
|
+
class ExampleModel(EiInfer):
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
The model is instantiated with the model name as its sole constructor
|
|
43
|
+
argument (``Model(model_name)``) and put into the registry at class
|
|
44
|
+
definition time; the worker pre-loads it via ``init()`` on startup, so
|
|
45
|
+
load model artifacts in ``init()`` rather than ``__init__``. Registering a
|
|
46
|
+
different class under an existing name raises.
|
|
47
|
+
"""
|
|
48
|
+
if not isinstance(model_name, str) or not model_name.strip():
|
|
49
|
+
raise ValueError("model name must be a non-empty string")
|
|
50
|
+
|
|
51
|
+
def decorator(model_cls: type[T]) -> type[T]:
|
|
52
|
+
_register_instance(model_name, model_cls(model_name)) # type: ignore
|
|
53
|
+
return model_cls
|
|
54
|
+
|
|
55
|
+
return decorator
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_model(name: str) -> EiInfer:
|
|
59
|
+
"""Return the registered model, raising KeyError when unknown."""
|
|
60
|
+
with _lock:
|
|
61
|
+
if name not in _registry:
|
|
62
|
+
raise KeyError(f"model '{name}' is not registered")
|
|
63
|
+
return _registry[name]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_registered_names() -> list[str]:
|
|
67
|
+
"""Return sorted registered model names (for startup pre-loading)."""
|
|
68
|
+
with _lock:
|
|
69
|
+
return sorted(_registry)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def clear_registry() -> None:
|
|
73
|
+
"""Drop all registrations. Test / restart hook only."""
|
|
74
|
+
with _lock:
|
|
75
|
+
_registry.clear()
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""arq task that turns a pipeline job into a call into the model registry."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from concurrent.futures.thread import ThreadPoolExecutor
|
|
5
|
+
|
|
6
|
+
from ei_path.paths import get_shared_dirpath, normalize_relative_path
|
|
7
|
+
|
|
8
|
+
from .env import infer_data_root
|
|
9
|
+
from .registry import get_model
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ModelTaskError(Exception):
|
|
13
|
+
"""A deterministic job failure (bad payload / unregistered model / bad result)."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def resolve_absolute_paths(
|
|
17
|
+
data: dict,
|
|
18
|
+
node_info: dict | None,
|
|
19
|
+
data_root: str,
|
|
20
|
+
) -> tuple[str, str]:
|
|
21
|
+
"""Resolve input/output to absolute paths on the shared PFS root.
|
|
22
|
+
|
|
23
|
+
The pipeline sends paths relative to the pipe shared dir; this joins them
|
|
24
|
+
onto {EI_INFER_DATA_ROOT}/{time_bucket}/{pipeline_id} with traversal
|
|
25
|
+
checks (mirrors the pipeline's pfs_storage.paths helpers). Returns
|
|
26
|
+
``(absolute_input, absolute_output)``; output is "" when data has no
|
|
27
|
+
output_path.
|
|
28
|
+
"""
|
|
29
|
+
raw_input = str(data.get("input_path") or "").strip()
|
|
30
|
+
if not raw_input:
|
|
31
|
+
raise ModelTaskError("job data requires 'input_path'")
|
|
32
|
+
normalized_input = normalize_relative_path(raw_input)
|
|
33
|
+
|
|
34
|
+
raw_output = data.get("output_path")
|
|
35
|
+
if raw_output is None:
|
|
36
|
+
normalized_output = ""
|
|
37
|
+
else:
|
|
38
|
+
stripped = str(raw_output).strip()
|
|
39
|
+
if not stripped:
|
|
40
|
+
normalized_output = ""
|
|
41
|
+
else:
|
|
42
|
+
normalized_output = normalize_relative_path(stripped)
|
|
43
|
+
|
|
44
|
+
base = get_shared_dirpath(data_root, node_info) # {root}/{time_bucket}/{pipe_id}
|
|
45
|
+
absolute_input = str(base / normalized_input)
|
|
46
|
+
absolute_output = (
|
|
47
|
+
str(base / normalized_output).rstrip("/") if normalized_output else ""
|
|
48
|
+
)
|
|
49
|
+
return absolute_input, absolute_output
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _WorkerExecutor:
|
|
53
|
+
"""Process-wide, lazily created thread pool for running sync inference.
|
|
54
|
+
|
|
55
|
+
arq runs each job as an async task; model.inference() is synchronous and
|
|
56
|
+
must not block the event loop, so it is executed in this pool. Created once
|
|
57
|
+
and shut down at the very end of the process.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
_pool: ThreadPoolExecutor | None = None
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def get(cls) -> ThreadPoolExecutor:
|
|
64
|
+
if cls._pool is None:
|
|
65
|
+
from .env import infer_max_threads
|
|
66
|
+
|
|
67
|
+
cls._pool = ThreadPoolExecutor(
|
|
68
|
+
max_workers=infer_max_threads(), thread_name_prefix="model-infer"
|
|
69
|
+
)
|
|
70
|
+
return cls._pool
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def shutdown(cls) -> None:
|
|
74
|
+
if cls._pool is not None:
|
|
75
|
+
cls._pool.shutdown(wait=True)
|
|
76
|
+
cls._pool = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def run_inference(ctx: dict, data: dict, node_info: dict | None = None) -> dict:
|
|
80
|
+
"""arq task served by ``ei_infer.worker``.
|
|
81
|
+
|
|
82
|
+
Protocol (shared with the ei-pipeline ``ei_infer`` node):
|
|
83
|
+
data = {"name": str, "input_path": str, "output_path": str, ...}
|
|
84
|
+
- ``name`` model registered in the in-process registry
|
|
85
|
+
- ``input_path`` PFS path relative to the pipe shared dir
|
|
86
|
+
{time_bucket}/{pipe_id}/... (may be a file or a dir)
|
|
87
|
+
- ``output_path``(optional) PFS dir the model may write result files to
|
|
88
|
+
- ``version`` optional, passed through untouched (reserved)
|
|
89
|
+
|
|
90
|
+
The worker resolves absolute paths from node_info + EI_INFER_DATA_ROOT:
|
|
91
|
+
absolute = {EI_INFER_DATA_ROOT}/{time_bucket}/{pipeline_id}/<path>
|
|
92
|
+
then calls ``model.inference(absolute_input, absolute_output, node_info)``.
|
|
93
|
+
The result dict is returned to the pipeline; the worker does NOT persist it
|
|
94
|
+
- writing result files is up to the model (into output_path if given).
|
|
95
|
+
|
|
96
|
+
Returns ``{"status": "success", "result": <inference dict>, "name": ...}``.
|
|
97
|
+
"""
|
|
98
|
+
name = data.get("name") or ""
|
|
99
|
+
if not name:
|
|
100
|
+
raise ModelTaskError("job data requires 'name'")
|
|
101
|
+
|
|
102
|
+
model = get_model(name) # KeyError when unknown -> job fails cleanly
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
absolute_input, absolute_output = resolve_absolute_paths(
|
|
106
|
+
data, node_info, infer_data_root()
|
|
107
|
+
)
|
|
108
|
+
except ValueError as error:
|
|
109
|
+
raise ModelTaskError(f"invalid data path (name={name}): {error}") from None
|
|
110
|
+
|
|
111
|
+
loop = asyncio.get_running_loop()
|
|
112
|
+
result = await loop.run_in_executor(
|
|
113
|
+
_WorkerExecutor.get(),
|
|
114
|
+
model.inference,
|
|
115
|
+
absolute_input,
|
|
116
|
+
absolute_output,
|
|
117
|
+
node_info or {},
|
|
118
|
+
)
|
|
119
|
+
if not isinstance(result, dict):
|
|
120
|
+
raise ModelTaskError(
|
|
121
|
+
f"model '{name}' inference returned {type(result).__name__}, expected dict"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
return {"status": "success", "result": result, "name": name}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""The ei-infer arq worker entry: ``python -m ei_infer.worker``.
|
|
2
|
+
|
|
3
|
+
The worker consumes the ``arq:{env}:ei:model-infer`` queue (override with
|
|
4
|
+
EI_INFER_QUEUE_NAME), pre-loads every model registered in the process on
|
|
5
|
+
startup, and dispatches each job to the matching model via a thread pool.
|
|
6
|
+
|
|
7
|
+
All tuning knobs come from the environment (EI_INFER_*); there are no CLI
|
|
8
|
+
arguments on purpose so containers only need env vars.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from arq import cron # noqa: F401 (documentation convenience)
|
|
12
|
+
|
|
13
|
+
from .env import (
|
|
14
|
+
infer_max_jobs,
|
|
15
|
+
infer_queue_name,
|
|
16
|
+
infer_redis_url,
|
|
17
|
+
redis_settings_from_url,
|
|
18
|
+
)
|
|
19
|
+
from .registry import get_registered_names
|
|
20
|
+
from .task import ModelTaskError, _WorkerExecutor, run_inference # noqa: F401
|
|
21
|
+
|
|
22
|
+
# Re-export so a project entry script can do `from ei_infer import worker`.
|
|
23
|
+
__all__ = ["build_worker_settings", "run_loop"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def startup(ctx: dict) -> None:
|
|
27
|
+
"""Pre-load every registered model. Any init() failure crashes the worker
|
|
28
|
+
on boot instead of surfacing on the first job."""
|
|
29
|
+
from .registry import get_model
|
|
30
|
+
|
|
31
|
+
for name in get_registered_names():
|
|
32
|
+
model = get_model(name)
|
|
33
|
+
model.init()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def shutdown(ctx: dict) -> None:
|
|
37
|
+
_WorkerExecutor.shutdown()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def build_worker_settings() -> type:
|
|
41
|
+
"""Build WorkerSettings at call time so env (EI_INFER_*) is read fresh.
|
|
42
|
+
|
|
43
|
+
The worker config must track env set at boot, not import-time snapshots.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
class _WorkerSettings:
|
|
47
|
+
functions = [run_inference]
|
|
48
|
+
queue_name = infer_queue_name()
|
|
49
|
+
redis_settings = redis_settings_from_url(infer_redis_url())
|
|
50
|
+
on_startup = startup
|
|
51
|
+
on_shutdown = shutdown
|
|
52
|
+
max_jobs = infer_max_jobs()
|
|
53
|
+
|
|
54
|
+
return _WorkerSettings
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def run_loop() -> None:
|
|
58
|
+
"""Run the worker with config read from EI_INFER_* env vars.
|
|
59
|
+
|
|
60
|
+
Entry for ``python -m ei_infer.worker``; blocks until interrupted.
|
|
61
|
+
"""
|
|
62
|
+
settings = build_worker_settings()
|
|
63
|
+
|
|
64
|
+
from arq.worker import Worker
|
|
65
|
+
|
|
66
|
+
worker = Worker(
|
|
67
|
+
functions=settings.functions,
|
|
68
|
+
queue_name=settings.queue_name,
|
|
69
|
+
redis_settings=settings.redis_settings,
|
|
70
|
+
on_startup=settings.on_startup,
|
|
71
|
+
on_shutdown=settings.on_shutdown,
|
|
72
|
+
max_jobs=settings.max_jobs,
|
|
73
|
+
handle_signals=False, # let the container runtime own signal handling
|
|
74
|
+
)
|
|
75
|
+
try:
|
|
76
|
+
worker.run()
|
|
77
|
+
finally:
|
|
78
|
+
_WorkerExecutor.shutdown()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Shared-dir path helpers for the algorithm worker (available as ``ei_path``)."""
|