pygpu-scheduling 0.1.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.
- pygpu_scheduling-0.1.0.dist-info/METADATA +93 -0
- pygpu_scheduling-0.1.0.dist-info/RECORD +13 -0
- pygpu_scheduling-0.1.0.dist-info/WHEEL +4 -0
- pygpu_scheduling-0.1.0.dist-info/licenses/LICENSE +21 -0
- task_scheduling/__init__.py +14 -0
- task_scheduling/gpu.py +90 -0
- task_scheduling/memory.py +45 -0
- task_scheduling/policy.py +46 -0
- task_scheduling/progress.py +131 -0
- task_scheduling/runtime.py +37 -0
- task_scheduling/scheduler.py +461 -0
- task_scheduling/storage.py +49 -0
- task_scheduling/task.py +197 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pygpu-scheduling
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Reusable local CPU and NVIDIA GPU task scheduling with dependency graphs and recovery hooks
|
|
5
|
+
Project-URL: Source, https://github.com/AllanJinYosoro/PyGPUScheduling
|
|
6
|
+
Project-URL: Issues, https://github.com/AllanJinYosoro/PyGPUScheduling/issues
|
|
7
|
+
Project-URL: Changelog, https://github.com/AllanJinYosoro/PyGPUScheduling/blob/main/CHANGELOG.md
|
|
8
|
+
Author: PyGPUScheduling contributors
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: gpu,nvidia,scheduler,subprocess,workflow
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: POSIX
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Provides-Extra: progress
|
|
19
|
+
Requires-Dist: tqdm>=4.66; extra == 'progress'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# PyGPUScheduling
|
|
23
|
+
|
|
24
|
+
单机 CPU 与 NVIDIA GPU 任务调度库。业务注册任务、依赖和生命周期处理器,调度器管理资源准入、子进程、重试及恢复。运行时仅依赖 Python 标准库,交互式进度条可选安装 `tqdm`。
|
|
25
|
+
|
|
26
|
+
分发包名为 `pygpu-scheduling`,Python 导入名为 `task_scheduling`。发布状态见[维护指南](https://github.com/AllanJinYosoro/PyGPUScheduling/blob/main/docs/maintaining.md#当前交接状态)。
|
|
27
|
+
|
|
28
|
+
## 环境与安装
|
|
29
|
+
|
|
30
|
+
需要 Python 3.11 或更新版本及 POSIX 系统。GPU 功能通过 `nvidia-smi` 工作,需要 NVIDIA 驱动;调度库不要求安装 CUDA Toolkit、Torch 或 Optuna。每个 GPU 任务使用一张卡,CPU 任务不查询 GPU。
|
|
31
|
+
|
|
32
|
+
在本仓库开发:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
uv sync --locked
|
|
36
|
+
uv run pytest
|
|
37
|
+
uv run python examples/basic.py
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
需要交互式进度条时:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
uv sync --locked --extra progress
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
在其他项目中安装:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
uv add "pygpu-scheduling>=0.1.0,<0.2.0"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
GitHub 固定 tag 接入及本地联调方式见[维护指南](https://github.com/AllanJinYosoro/PyGPUScheduling/blob/main/docs/maintaining.md#业务仓库接入)。
|
|
53
|
+
|
|
54
|
+
## 注册任务
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
import sys
|
|
58
|
+
|
|
59
|
+
from task_scheduling import Scheduler, Scheduling, TaskSpec
|
|
60
|
+
|
|
61
|
+
scheduler = Scheduler("runs/example", Scheduling(max_parallel_tasks=2))
|
|
62
|
+
scheduler.submit_many([
|
|
63
|
+
TaskSpec("prepare", "command", payload={"args": [sys.executable, "prepare.py"]}),
|
|
64
|
+
TaskSpec("train", "command", dependencies=("prepare",),
|
|
65
|
+
payload={"args": [sys.executable, "train.py"]}),
|
|
66
|
+
])
|
|
67
|
+
records = scheduler.run()
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`command` 是按退出码判定结果的内置处理器。`run()` 返回全部任务记录,调用方检查 `complete`、`failed`、`blocked` 等状态并决定业务退出码。
|
|
71
|
+
|
|
72
|
+
GPU 任务通过 `resources=ResourceRequest("cuda", 4096)` 声明 4096 MiB 预算。预算用于启动准入,不是显存硬限制。调度器不解释模型、训练轮数、业务参数或产物文件。
|
|
73
|
+
|
|
74
|
+
## 功能与边界
|
|
75
|
+
|
|
76
|
+
- 静态依赖图与动态追加任务,拒绝缺失依赖、重复定义和依赖环。
|
|
77
|
+
- 按优先级、显存预算和提交顺序选择任务,支持共享或独占 GPU。
|
|
78
|
+
- 启动前预留显存,结合实际进程占用更新预算,保留每卡安全余量。
|
|
79
|
+
- 子进程日志、超时、有限重试、GPU 冷却和故障隔离。
|
|
80
|
+
- 注册 `prepare`、`assess`、`commit`、`recover`,由业务提供校验及产物流程。
|
|
81
|
+
- 持久化任务状态,完成事件按至少一次语义重放。
|
|
82
|
+
|
|
83
|
+
当前提供单机本地子进程执行,不包含多机、抢占或单任务多卡调度。预算账本属于单个调度实例;跨实例不会合并预算。更完整的约束见[API 与恢复协议](https://github.com/AllanJinYosoro/PyGPUScheduling/blob/main/docs/api.md)。
|
|
84
|
+
|
|
85
|
+
## 文档与示例
|
|
86
|
+
|
|
87
|
+
- [API、调度策略和恢复协议](https://github.com/AllanJinYosoro/PyGPUScheduling/blob/main/docs/api.md)
|
|
88
|
+
- [开发与发布](https://github.com/AllanJinYosoro/PyGPUScheduling/blob/main/docs/maintaining.md)
|
|
89
|
+
- [变更记录](https://github.com/AllanJinYosoro/PyGPUScheduling/blob/main/CHANGELOG.md)
|
|
90
|
+
- [依赖顺序示例](https://github.com/AllanJinYosoro/PyGPUScheduling/blob/main/examples/basic.py)
|
|
91
|
+
- [业务产物处理示例](https://github.com/AllanJinYosoro/PyGPUScheduling/blob/main/examples/artifacts.py)
|
|
92
|
+
|
|
93
|
+
采用 [MIT 许可证](https://github.com/AllanJinYosoro/PyGPUScheduling/blob/main/LICENSE)。
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
task_scheduling/__init__.py,sha256=E3Sk8j90tz1ny5EN1XbLTJVsTkxcnhhg8SBXYTeGhsU,513
|
|
2
|
+
task_scheduling/gpu.py,sha256=3rX6tfD0aLviDbdDx1osBTzDFSHDHeUSPGmPgnoC84w,3679
|
|
3
|
+
task_scheduling/memory.py,sha256=JTF-B5HtmnISzrmv_Cc91FljCtix4nAkKkl1cGgPROc,1940
|
|
4
|
+
task_scheduling/policy.py,sha256=-sMVPieIVx5qPxq6fhM-pT3ZyH6jHUF4mJn5IKeYZk0,2087
|
|
5
|
+
task_scheduling/progress.py,sha256=kS4x7ZDMwZSxwGrnh_x_9SR_HX0GQyKcfWhTcPqy3p4,5555
|
|
6
|
+
task_scheduling/runtime.py,sha256=YnHPljoZL-XaWBJWGvTQ2eZ5k-ma-0-q0Ilf-Y2JFI4,1332
|
|
7
|
+
task_scheduling/scheduler.py,sha256=Y3gzKyD64DlubsMnAqcWnQ0cMOtewEjIX6QvV9GMwmc,21607
|
|
8
|
+
task_scheduling/storage.py,sha256=q3gk8zt2uChTOPRlACQC0Z3fd8FAGl4GP7R0QD1f03g,1988
|
|
9
|
+
task_scheduling/task.py,sha256=Mo0pxnImk12_S_1pnGOX0vdWxM3BalcgfXjKHwHz6MI,6848
|
|
10
|
+
pygpu_scheduling-0.1.0.dist-info/METADATA,sha256=pADOEm7CSc3beNY5fwH5-kyZ14uTTVvODWVUbrzUJNM,4357
|
|
11
|
+
pygpu_scheduling-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
12
|
+
pygpu_scheduling-0.1.0.dist-info/licenses/LICENSE,sha256=Ni80rrOgBgnqG6v0XpmDABF5_g-w05-_v7mvlwrv6Hs,1085
|
|
13
|
+
pygpu_scheduling-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 PyGPUScheduling contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from .policy import Scheduling
|
|
2
|
+
from .runtime import LocalRuntime
|
|
3
|
+
from .scheduler import Scheduler
|
|
4
|
+
from .task import (
|
|
5
|
+
Assessment, Attempt, Command, CommandHandler, Recovery, ResourceRequest,
|
|
6
|
+
TaskContext, TaskEvent, TaskHandler, TaskOutcome, TaskRecord, TaskSpec,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"Assessment", "Attempt", "Command", "CommandHandler", "LocalRuntime", "Recovery",
|
|
12
|
+
"ResourceRequest", "Scheduler", "Scheduling", "TaskContext", "TaskEvent", "TaskHandler",
|
|
13
|
+
"TaskOutcome", "TaskRecord", "TaskSpec",
|
|
14
|
+
]
|
task_scheduling/gpu.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
import csv
|
|
3
|
+
import io
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
|
|
7
|
+
from .policy import Scheduling
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GPUQueryError(RuntimeError):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class GPU:
|
|
16
|
+
index: int
|
|
17
|
+
uuid: str
|
|
18
|
+
free_mib: int
|
|
19
|
+
busy: bool = False
|
|
20
|
+
process_memory_mib: dict[int, int] = field(default_factory=dict)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _query(fields: str, kind: str) -> str:
|
|
24
|
+
try:
|
|
25
|
+
result = subprocess.run(
|
|
26
|
+
["nvidia-smi", f"--query-{kind}={fields}", "--format=csv,noheader,nounits"],
|
|
27
|
+
capture_output=True, text=True, timeout=5, check=False,
|
|
28
|
+
)
|
|
29
|
+
except (OSError, subprocess.TimeoutExpired) as error:
|
|
30
|
+
raise GPUQueryError(f"GPU query unavailable: {error}") from error
|
|
31
|
+
if result.returncode and (kind != "gpu" or not result.stdout.strip()):
|
|
32
|
+
raise GPUQueryError(result.stderr.strip() or "nvidia-smi query failed")
|
|
33
|
+
return result.stdout
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def discover() -> list[GPU]:
|
|
37
|
+
devices = []
|
|
38
|
+
for row in csv.reader(io.StringIO(_query("index,uuid,memory.free", "gpu"))):
|
|
39
|
+
try:
|
|
40
|
+
index, uuid, free = [part.strip() for part in row]
|
|
41
|
+
if uuid.startswith("GPU-"):
|
|
42
|
+
devices.append(GPU(int(index), uuid, int(free)))
|
|
43
|
+
except (ValueError, TypeError):
|
|
44
|
+
continue
|
|
45
|
+
if not devices:
|
|
46
|
+
raise GPUQueryError("nvidia-smi returned no healthy GPU records")
|
|
47
|
+
processes = {}
|
|
48
|
+
for row in csv.reader(io.StringIO(_query("gpu_uuid,pid,used_gpu_memory", "compute-apps"))):
|
|
49
|
+
if len(row) == 3 and row[0].strip().startswith("GPU-"):
|
|
50
|
+
try:
|
|
51
|
+
uuid, pid, memory = [part.strip() for part in row]
|
|
52
|
+
used = 0 if memory in {"N/A", "[N/A]", "[Not Supported]"} else int(memory)
|
|
53
|
+
if int(pid) <= 0 or used < 0:
|
|
54
|
+
raise ValueError
|
|
55
|
+
processes.setdefault(uuid, {})[int(pid)] = used
|
|
56
|
+
except ValueError:
|
|
57
|
+
raise GPUQueryError("could not determine GPU process memory") from None
|
|
58
|
+
elif row and any(part.strip() for part in row) and row[0].strip() != "No running processes found":
|
|
59
|
+
raise GPUQueryError("could not parse GPU compute-process occupancy")
|
|
60
|
+
return [GPU(g.index, g.uuid, g.free_mib, g.uuid in processes, processes.get(g.uuid, {})) for g in devices]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _matches(gpu: GPU, token: int | str) -> bool:
|
|
64
|
+
return str(gpu.index) == str(token) or (
|
|
65
|
+
isinstance(token, str) and token.startswith("GPU-") and gpu.uuid.startswith(token)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def visible_devices(devices: list[GPU], policy: Scheduling) -> list[GPU]:
|
|
70
|
+
visible = os.environ.get("CUDA_VISIBLE_DEVICES")
|
|
71
|
+
visible_tokens = None if visible is None else [part.strip() for part in visible.split(",")]
|
|
72
|
+
result = []
|
|
73
|
+
for gpu in devices:
|
|
74
|
+
if visible_tokens is not None and not any(_matches(gpu, token) for token in visible_tokens):
|
|
75
|
+
continue
|
|
76
|
+
if policy.allowed_gpus is not None and not any(_matches(gpu, token) for token in policy.allowed_gpus):
|
|
77
|
+
continue
|
|
78
|
+
result.append(gpu)
|
|
79
|
+
return result
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def eligible(devices: list[GPU], policy: Scheduling, reserved: set[str],
|
|
83
|
+
cooldown: dict[str, float], quarantined: set[str], now: float,
|
|
84
|
+
memory_mib: int | None = None) -> list[GPU]:
|
|
85
|
+
minimum = policy.min_free_memory_mib if memory_mib is None else memory_mib
|
|
86
|
+
result = [gpu for gpu in visible_devices(devices, policy)
|
|
87
|
+
if gpu.uuid not in quarantined
|
|
88
|
+
and cooldown.get(gpu.uuid, 0) <= now and gpu.free_mib > minimum
|
|
89
|
+
and not (policy.require_idle and (gpu.busy or gpu.uuid in reserved))]
|
|
90
|
+
return sorted(result, key=lambda gpu: (-gpu.free_mib, gpu.index))
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from dataclasses import dataclass, replace
|
|
2
|
+
|
|
3
|
+
from .gpu import GPU, eligible, visible_devices
|
|
4
|
+
from .policy import Scheduling
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class Reservation:
|
|
9
|
+
gpu_uuid: str
|
|
10
|
+
memory_mib: int
|
|
11
|
+
pid: int | None = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MemoryPool:
|
|
15
|
+
def __init__(self, policy: Scheduling):
|
|
16
|
+
self.policy = policy
|
|
17
|
+
self.devices: list[GPU] = []
|
|
18
|
+
self.reservations: dict[int, Reservation] = {}
|
|
19
|
+
|
|
20
|
+
def refresh(self, devices: list[GPU]) -> None:
|
|
21
|
+
self.devices = visible_devices(devices, self.policy)
|
|
22
|
+
|
|
23
|
+
def available(self, gpu: GPU) -> int:
|
|
24
|
+
reserved = sum(max(0, reservation.memory_mib - gpu.process_memory_mib.get(reservation.pid, 0))
|
|
25
|
+
for reservation in self.reservations.values() if reservation.gpu_uuid == gpu.uuid)
|
|
26
|
+
return max(0, gpu.free_mib - self.policy.memory_margin_mib - reserved)
|
|
27
|
+
|
|
28
|
+
def candidates(self, memory_mib: int, cooldown: dict[str, float], quarantined: set[str],
|
|
29
|
+
now: float, last_gpu: str | None = None) -> list[GPU]:
|
|
30
|
+
reserved = {reservation.gpu_uuid for reservation in self.reservations.values()}
|
|
31
|
+
devices = eligible(self.devices, self.policy, reserved, cooldown, quarantined, now, memory_mib=0)
|
|
32
|
+
return sorted((gpu for gpu in devices if self.available(gpu) >= memory_mib),
|
|
33
|
+
key=lambda gpu: (gpu.uuid == last_gpu, self.available(gpu), gpu.index))
|
|
34
|
+
|
|
35
|
+
def reserve(self, identifier: int, gpu: GPU, memory_mib: int) -> None:
|
|
36
|
+
self.reservations[identifier] = Reservation(gpu.uuid, memory_mib)
|
|
37
|
+
|
|
38
|
+
def started(self, identifier: int, pid: int) -> None:
|
|
39
|
+
self.reservations[identifier] = replace(self.reservations[identifier], pid=pid)
|
|
40
|
+
|
|
41
|
+
def release(self, identifier: int) -> None:
|
|
42
|
+
self.reservations.pop(identifier, None)
|
|
43
|
+
|
|
44
|
+
def summary(self) -> str:
|
|
45
|
+
return ", ".join(f"GPU{gpu.index} available {self.available(gpu) / 1024:g}GiB" for gpu in self.devices)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def integer(value: int, name: str, minimum: int = 0) -> None:
|
|
5
|
+
if type(value) is not int or value < minimum:
|
|
6
|
+
raise ValueError(f"{name} must be an integer >= {minimum}")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class Scheduling:
|
|
11
|
+
allowed_gpus: tuple[int | str, ...] | None = None
|
|
12
|
+
min_free_memory_mib: int = 4096
|
|
13
|
+
memory_margin_mib: int = 0
|
|
14
|
+
max_parallel_tasks: int = 3
|
|
15
|
+
require_idle: bool = False
|
|
16
|
+
max_retries: int = 3
|
|
17
|
+
poll_interval_seconds: int = 10
|
|
18
|
+
gpu_cooldown_seconds: int = 60
|
|
19
|
+
task_timeout_seconds: int = 0
|
|
20
|
+
wait_seconds: int = 3600
|
|
21
|
+
|
|
22
|
+
def __post_init__(self) -> None:
|
|
23
|
+
for name in ("min_free_memory_mib", "memory_margin_mib", "max_retries",
|
|
24
|
+
"gpu_cooldown_seconds", "task_timeout_seconds"):
|
|
25
|
+
integer(getattr(self, name), name)
|
|
26
|
+
for name in ("max_parallel_tasks", "poll_interval_seconds", "wait_seconds"):
|
|
27
|
+
integer(getattr(self, name), name, 1)
|
|
28
|
+
if type(self.require_idle) is not bool:
|
|
29
|
+
raise ValueError("require_idle must be a boolean")
|
|
30
|
+
if self.allowed_gpus is not None:
|
|
31
|
+
allowed = self.allowed_gpus
|
|
32
|
+
if not isinstance(allowed, (list, tuple)) or not allowed:
|
|
33
|
+
raise ValueError("allowed_gpus must be null or a nonempty list of GPU indices/UUIDs")
|
|
34
|
+
if any(not ((type(gpu) is int and gpu >= 0) or
|
|
35
|
+
(isinstance(gpu, str) and gpu.startswith("GPU-") and len(gpu) > 4))
|
|
36
|
+
for gpu in allowed):
|
|
37
|
+
raise ValueError("allowed_gpus entries must be nonnegative indices or GPU- UUIDs")
|
|
38
|
+
if len(set(allowed)) != len(allowed):
|
|
39
|
+
raise ValueError("allowed_gpus must not contain duplicates")
|
|
40
|
+
object.__setattr__(self, "allowed_gpus", tuple(allowed))
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def from_settings(cls, settings: dict) -> "Scheduling":
|
|
44
|
+
if not isinstance(settings, dict) or set(settings) - cls.__dataclass_fields__.keys():
|
|
45
|
+
raise ValueError("Unknown scheduling settings")
|
|
46
|
+
return cls(**settings)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from contextlib import ExitStack
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
import shutil
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
from typing import TextIO
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class WorkerStatus:
|
|
11
|
+
identifier: int
|
|
12
|
+
pid: int
|
|
13
|
+
device: str
|
|
14
|
+
attempt: int
|
|
15
|
+
current: int
|
|
16
|
+
total: int
|
|
17
|
+
phase: str
|
|
18
|
+
started: float
|
|
19
|
+
unit: str = "epoch"
|
|
20
|
+
memory_mib: int = 0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def duration(seconds: float) -> str:
|
|
24
|
+
seconds = max(0, int(seconds))
|
|
25
|
+
minutes, seconds = divmod(seconds, 60)
|
|
26
|
+
hours, minutes = divmod(minutes, 60)
|
|
27
|
+
days, hours = divmod(hours, 24)
|
|
28
|
+
if days:
|
|
29
|
+
return f"{days}d{hours:02d}h"
|
|
30
|
+
if hours:
|
|
31
|
+
return f"{hours}h{minutes:02d}m"
|
|
32
|
+
if minutes:
|
|
33
|
+
return f"{minutes}m{seconds:02d}s"
|
|
34
|
+
return f"{seconds}s"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TaskProgress:
|
|
38
|
+
|
|
39
|
+
def __init__(self, records: list[dict], slots: int, max_retries: int,
|
|
40
|
+
*, label: str = "Tasks", stream: TextIO | None = None):
|
|
41
|
+
self.label = label
|
|
42
|
+
self.records, self.slots, self.max_attempts = records, slots, max_retries + 1
|
|
43
|
+
self.stream = sys.stderr if stream is None else stream
|
|
44
|
+
self.interactive = self.stream.isatty()
|
|
45
|
+
self.started = time.monotonic()
|
|
46
|
+
self.initial_completed = sum(record["status"] == "complete" for record in records)
|
|
47
|
+
self.slot_ids: list[int | None] = [None] * slots
|
|
48
|
+
self.lines: list = []
|
|
49
|
+
self.last_signature = None
|
|
50
|
+
self.last_printed = float("-inf")
|
|
51
|
+
self.stack = ExitStack()
|
|
52
|
+
|
|
53
|
+
def __enter__(self):
|
|
54
|
+
try:
|
|
55
|
+
from tqdm.auto import tqdm
|
|
56
|
+
except ImportError:
|
|
57
|
+
self.interactive = False
|
|
58
|
+
if self.interactive:
|
|
59
|
+
try:
|
|
60
|
+
for position in range(self.slots + 2):
|
|
61
|
+
self.lines.append(self.stack.enter_context(tqdm(
|
|
62
|
+
total=0, bar_format="{desc}", position=position,
|
|
63
|
+
leave=position == 0, dynamic_ncols=True, file=self.stream,
|
|
64
|
+
)))
|
|
65
|
+
except BaseException:
|
|
66
|
+
self.stack.close()
|
|
67
|
+
raise
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
def __exit__(self, *exception):
|
|
71
|
+
return self.stack.__exit__(*exception)
|
|
72
|
+
|
|
73
|
+
def snapshot(self, workers: list[WorkerStatus], event: str, *, now: float,
|
|
74
|
+
width: int) -> list[str]:
|
|
75
|
+
by_id = {worker.identifier: worker for worker in workers}
|
|
76
|
+
self.slot_ids = [identifier if identifier in by_id else None for identifier in self.slot_ids]
|
|
77
|
+
for worker in workers:
|
|
78
|
+
if worker.identifier not in self.slot_ids:
|
|
79
|
+
self.slot_ids[self.slot_ids.index(None)] = worker.identifier
|
|
80
|
+
|
|
81
|
+
completed = sum(record["status"] == "complete" for record in self.records)
|
|
82
|
+
failed = sum(record["status"] in {"failed", "blocked"} for record in self.records)
|
|
83
|
+
pending = sum(record["status"] == "pending" for record in self.records)
|
|
84
|
+
retries = sum(max(0, len(record.get("attempts", [])) - 1) for record in self.records)
|
|
85
|
+
total = len(self.records)
|
|
86
|
+
elapsed = max(0, now - self.started)
|
|
87
|
+
fresh_completed = completed - self.initial_completed
|
|
88
|
+
remaining = total - completed - failed
|
|
89
|
+
if remaining == 0:
|
|
90
|
+
eta = "0s"
|
|
91
|
+
elif fresh_completed > 0 and elapsed > 0:
|
|
92
|
+
eta = "~" + duration(remaining * elapsed / fresh_completed)
|
|
93
|
+
else:
|
|
94
|
+
eta = "estimating"
|
|
95
|
+
count = f"{self.label} {completed}/{total}"
|
|
96
|
+
fraction = completed / total if total else 1
|
|
97
|
+
tail = (f" {fraction:.0%} | run{len(workers)}/{self.slots} wait{pending} fail{failed} retry{retries}"
|
|
98
|
+
f" | t{duration(elapsed)} ETA {eta}")
|
|
99
|
+
filled = int(20 * fraction)
|
|
100
|
+
bar = " [" + "#" * filled + "-" * (20 - filled) + "]"
|
|
101
|
+
header = count + (bar if len(count + bar + tail) <= width else "") + tail
|
|
102
|
+
result = [header]
|
|
103
|
+
for index, identifier in enumerate(self.slot_ids, start=1):
|
|
104
|
+
if identifier is None:
|
|
105
|
+
result.append(f"Slot {index} | idle")
|
|
106
|
+
continue
|
|
107
|
+
worker = by_id[identifier]
|
|
108
|
+
budget = f" budget{worker.memory_mib / 1024:g}GiB" if worker.memory_mib else ""
|
|
109
|
+
result.append(
|
|
110
|
+
f"Slot {index} | {worker.device} PID{worker.pid}{budget} | {self.label} {identifier:03d} | "
|
|
111
|
+
f"try{worker.attempt}/{self.max_attempts} | {worker.unit}{worker.current}/{worker.total} | "
|
|
112
|
+
f"{worker.phase} | elapsed {duration(now - worker.started)}"
|
|
113
|
+
)
|
|
114
|
+
result.append("Latest: " + " ".join(event.split()))
|
|
115
|
+
return [line if len(line) <= width else line[:max(0, width - 3)] + "..." for line in result]
|
|
116
|
+
|
|
117
|
+
def render(self, workers: list[WorkerStatus], event: str) -> None:
|
|
118
|
+
now = time.monotonic()
|
|
119
|
+
width = max(20, shutil.get_terminal_size(fallback=(120, 24)).columns) if self.interactive else 160
|
|
120
|
+
frame = self.snapshot(workers, event, now=now, width=width)
|
|
121
|
+
if self.interactive:
|
|
122
|
+
for line, text in zip(self.lines, frame):
|
|
123
|
+
line.set_description_str(text, refresh=False)
|
|
124
|
+
line.refresh()
|
|
125
|
+
else:
|
|
126
|
+
signature = (tuple((r["status"], len(r.get("attempts", []))) for r in self.records),
|
|
127
|
+
tuple((w.identifier, w.pid, w.current, w.phase) for w in workers), event)
|
|
128
|
+
if signature != self.last_signature or now - self.last_printed >= 60:
|
|
129
|
+
self.stream.write("\n".join(frame) + "\n")
|
|
130
|
+
self.stream.flush()
|
|
131
|
+
self.last_printed, self.last_signature = now, signature
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import signal
|
|
5
|
+
import subprocess
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
from .gpu import GPU, discover
|
|
9
|
+
from .task import Command
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def stop_process_tree(process: subprocess.Popen) -> None:
|
|
13
|
+
for sig in (signal.SIGTERM, signal.SIGKILL):
|
|
14
|
+
try:
|
|
15
|
+
os.killpg(process.pid, sig)
|
|
16
|
+
except ProcessLookupError:
|
|
17
|
+
pass
|
|
18
|
+
if sig == signal.SIGTERM:
|
|
19
|
+
try:
|
|
20
|
+
process.wait(timeout=5)
|
|
21
|
+
except subprocess.TimeoutExpired:
|
|
22
|
+
pass
|
|
23
|
+
process.wait()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class LocalRuntime:
|
|
28
|
+
discover: Callable[[], list[GPU]] = discover
|
|
29
|
+
stop: Callable[[subprocess.Popen], None] = stop_process_tree
|
|
30
|
+
|
|
31
|
+
def launch(self, command: Command, directory: Path, gpu: GPU | None, inherited_fds: tuple[int, ...]):
|
|
32
|
+
environment = {**os.environ, **command.env, "PYTHONUNBUFFERED": "1",
|
|
33
|
+
"CUDA_VISIBLE_DEVICES": "" if gpu is None else gpu.uuid,
|
|
34
|
+
"TASK_PROGRESS_FILE": str(directory / "progress.json")}
|
|
35
|
+
with (directory / "worker.log").open("w") as log:
|
|
36
|
+
return subprocess.Popen(command.args, cwd=command.cwd, env=environment, stdout=log,
|
|
37
|
+
stderr=subprocess.STDOUT, start_new_session=True, pass_fds=inherited_fds)
|
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
from collections import deque
|
|
2
|
+
from contextlib import ExitStack
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from dataclasses import asdict, dataclass
|
|
5
|
+
import fcntl
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import re
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
import time
|
|
13
|
+
from typing import Callable, Iterable, TextIO
|
|
14
|
+
|
|
15
|
+
from .gpu import GPU, GPUQueryError, visible_devices
|
|
16
|
+
from .memory import MemoryPool
|
|
17
|
+
from .policy import Scheduling
|
|
18
|
+
from .progress import TaskProgress, WorkerStatus
|
|
19
|
+
from .runtime import LocalRuntime
|
|
20
|
+
from .storage import StateStore
|
|
21
|
+
from .task import (
|
|
22
|
+
Assessment, Attempt, CommandHandler, TaskContext, TaskEvent, TaskHandler,
|
|
23
|
+
TaskOutcome, TaskRecord, TaskSpec,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class RunningTask:
|
|
29
|
+
process: subprocess.Popen
|
|
30
|
+
gpu: GPU | None
|
|
31
|
+
lock: TextIO | None
|
|
32
|
+
directory: Path
|
|
33
|
+
started: float
|
|
34
|
+
missing: int = 0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Scheduler:
|
|
38
|
+
def __init__(self, root: str | Path, policy: Scheduling | None = None, *,
|
|
39
|
+
runtime: LocalRuntime | None = None, inherited_fds: tuple[int, ...] = (),
|
|
40
|
+
lock_namespace: str = "task-scheduling", label: str = "Tasks"):
|
|
41
|
+
if not re.fullmatch(r"[A-Za-z0-9_-]+", lock_namespace):
|
|
42
|
+
raise ValueError("Lock namespace must contain only letters, digits, underscores and hyphens")
|
|
43
|
+
self.root = Path(root).resolve()
|
|
44
|
+
self.policy = policy or Scheduling()
|
|
45
|
+
self.runtime = runtime or LocalRuntime()
|
|
46
|
+
self.label = label
|
|
47
|
+
self.lock_namespace = lock_namespace
|
|
48
|
+
self.inherited_fds = inherited_fds
|
|
49
|
+
self.store = StateStore(self.root)
|
|
50
|
+
self.memory = MemoryPool(self.policy)
|
|
51
|
+
self._records: dict[str, TaskRecord] = {}
|
|
52
|
+
self._handlers: dict[str, TaskHandler] = {"command": CommandHandler()}
|
|
53
|
+
self._listeners: dict[str, Callable[[TaskEvent], None]] = {}
|
|
54
|
+
self._observers: list[Callable[[tuple[TaskRecord, ...]], None]] = []
|
|
55
|
+
self._running: dict[str, RunningTask] = {}
|
|
56
|
+
self._cooldown: dict[str, float] = {}
|
|
57
|
+
self._quarantined: set[str] = set()
|
|
58
|
+
self._known: set[str] = set()
|
|
59
|
+
self._reconciled: set[str] = set()
|
|
60
|
+
self._session: ExitStack | None = None
|
|
61
|
+
self._lock_fd: int | None = None
|
|
62
|
+
self._event = "Preparing tasks"
|
|
63
|
+
self._query_error = ""
|
|
64
|
+
self._next_scan = 0.0
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def tasks(self) -> tuple[TaskRecord, ...]:
|
|
68
|
+
return tuple(deepcopy(record) for record in self._records.values())
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def has_saved_state(self) -> bool:
|
|
72
|
+
return self.store.path.exists()
|
|
73
|
+
|
|
74
|
+
def __enter__(self) -> "Scheduler":
|
|
75
|
+
if self._session is not None:
|
|
76
|
+
raise RuntimeError("Scheduler session is already open")
|
|
77
|
+
session = ExitStack()
|
|
78
|
+
try:
|
|
79
|
+
self._lock_fd = session.enter_context(self.store.lock())
|
|
80
|
+
submitted = [record.spec for record in self._records.values()]
|
|
81
|
+
self._records = self.store.load()
|
|
82
|
+
self.memory = MemoryPool(self.policy)
|
|
83
|
+
self._cooldown.clear()
|
|
84
|
+
self._quarantined.clear()
|
|
85
|
+
self._known.clear()
|
|
86
|
+
self._next_scan = 0.0
|
|
87
|
+
self._session = session
|
|
88
|
+
self.submit_many(submitted)
|
|
89
|
+
except BaseException:
|
|
90
|
+
self._session = None
|
|
91
|
+
session.close()
|
|
92
|
+
raise
|
|
93
|
+
return self
|
|
94
|
+
|
|
95
|
+
def __exit__(self, *exception) -> None:
|
|
96
|
+
try:
|
|
97
|
+
if self._running:
|
|
98
|
+
self._interrupt_running()
|
|
99
|
+
self._save()
|
|
100
|
+
finally:
|
|
101
|
+
session, self._session = self._session, None
|
|
102
|
+
self._lock_fd = None
|
|
103
|
+
self._reconciled.clear()
|
|
104
|
+
if session is not None:
|
|
105
|
+
session.__exit__(*exception)
|
|
106
|
+
|
|
107
|
+
def register_handler(self, name: str, handler: TaskHandler) -> None:
|
|
108
|
+
if not isinstance(name, str) or not name.strip():
|
|
109
|
+
raise ValueError("Handler name must be a nonempty string")
|
|
110
|
+
if name in self._handlers:
|
|
111
|
+
raise ValueError(f"Handler already registered: {name}")
|
|
112
|
+
if any(not callable(getattr(handler, method, None)) for method in ("prepare", "assess", "commit", "recover")):
|
|
113
|
+
raise TypeError("Handlers must implement prepare, assess, commit and recover")
|
|
114
|
+
self._handlers[name] = handler
|
|
115
|
+
|
|
116
|
+
def subscribe(self, name: str, listener: Callable[[TaskEvent], None]) -> None:
|
|
117
|
+
if not isinstance(name, str) or not name.strip() or name in self._listeners:
|
|
118
|
+
raise ValueError(f"Listener name is empty or already registered: {name}")
|
|
119
|
+
if not callable(listener):
|
|
120
|
+
raise TypeError("Event listeners must be callable")
|
|
121
|
+
self._listeners[name] = listener
|
|
122
|
+
|
|
123
|
+
def observe(self, observer: Callable[[tuple[TaskRecord, ...]], None]) -> None:
|
|
124
|
+
if not callable(observer):
|
|
125
|
+
raise TypeError("State observers must be callable")
|
|
126
|
+
self._observers.append(observer)
|
|
127
|
+
|
|
128
|
+
def submit(self, task: TaskSpec) -> None:
|
|
129
|
+
self.submit_many([task])
|
|
130
|
+
|
|
131
|
+
def submit_many(self, tasks: Iterable[TaskSpec]) -> None:
|
|
132
|
+
submitted = [TaskSpec.from_dict(json.loads(json.dumps(asdict(task), allow_nan=False))) for task in tasks]
|
|
133
|
+
specs = {identifier: record.spec for identifier, record in self._records.items()}
|
|
134
|
+
for task in submitted:
|
|
135
|
+
if task.id in specs and specs[task.id].identity() != task.identity():
|
|
136
|
+
raise ValueError(f"Task definition changed: {task.id}")
|
|
137
|
+
existing = self._records.get(task.id)
|
|
138
|
+
if existing is not None and task.id in self._running and existing.spec != task:
|
|
139
|
+
raise ValueError(f"Cannot update an executing task: {task.id}")
|
|
140
|
+
specs[task.id] = task
|
|
141
|
+
self._validate_graph(specs)
|
|
142
|
+
for task in submitted:
|
|
143
|
+
if task.id in self._records:
|
|
144
|
+
self._records[task.id].spec = task
|
|
145
|
+
else:
|
|
146
|
+
self._records[task.id] = TaskRecord(task, len(self._records))
|
|
147
|
+
if submitted:
|
|
148
|
+
self._save()
|
|
149
|
+
|
|
150
|
+
def import_records(self, records: Iterable[TaskRecord]) -> None:
|
|
151
|
+
self._require_session()
|
|
152
|
+
if self._records or self.store.path.exists():
|
|
153
|
+
raise ValueError("Recovery records can only be imported into a new scheduler")
|
|
154
|
+
imported = deepcopy(list(records))
|
|
155
|
+
if len({record.spec.id for record in imported}) != len(imported):
|
|
156
|
+
raise ValueError("Duplicate task ids in recovery records")
|
|
157
|
+
if [record.sequence for record in imported] != list(range(len(imported))):
|
|
158
|
+
raise ValueError("Recovery records must have contiguous sequence numbers")
|
|
159
|
+
if any(record.status not in {"pending", "running", "committing", "complete", "failed", "blocked"}
|
|
160
|
+
for record in imported):
|
|
161
|
+
raise ValueError("Unknown task status in recovery records")
|
|
162
|
+
self._validate_graph({record.spec.id: record.spec for record in imported})
|
|
163
|
+
self._records = {record.spec.id: record for record in imported}
|
|
164
|
+
self._save()
|
|
165
|
+
|
|
166
|
+
def reconcile(self) -> None:
|
|
167
|
+
self._require_session()
|
|
168
|
+
self._validate_graph({identifier: record.spec for identifier, record in self._records.items()})
|
|
169
|
+
for record in list(self._records.values()):
|
|
170
|
+
if record.spec.id in self._reconciled:
|
|
171
|
+
continue
|
|
172
|
+
handler = self._handler(record)
|
|
173
|
+
recovery = handler.recover(self._context(record))
|
|
174
|
+
if recovery is not None:
|
|
175
|
+
record.status = recovery.status
|
|
176
|
+
record.result = deepcopy(recovery.result)
|
|
177
|
+
if record.status == "complete" and record.attempts:
|
|
178
|
+
record.attempts[-1].status = "complete"
|
|
179
|
+
elif record.status == "complete":
|
|
180
|
+
raise ValueError(f"Completed task could not be recovered: {record.spec.id}")
|
|
181
|
+
elif record.status == "committing":
|
|
182
|
+
raise ValueError(f"Task publication could not be recovered: {record.spec.id}")
|
|
183
|
+
else:
|
|
184
|
+
for attempt in record.attempts:
|
|
185
|
+
if attempt.status == "running":
|
|
186
|
+
attempt.status = "interrupted"
|
|
187
|
+
record.status = "pending" if record.can_retry(self.policy.max_retries) else "failed"
|
|
188
|
+
self._reconciled.add(record.spec.id)
|
|
189
|
+
self._save()
|
|
190
|
+
if record.status == "committing":
|
|
191
|
+
self._commit(record)
|
|
192
|
+
|
|
193
|
+
def run(self) -> tuple[TaskRecord, ...]:
|
|
194
|
+
if self._session is None:
|
|
195
|
+
with self:
|
|
196
|
+
return self.run()
|
|
197
|
+
self.reconcile()
|
|
198
|
+
self._deliver_events()
|
|
199
|
+
deadline = time.monotonic() + self.policy.wait_seconds
|
|
200
|
+
display_records = [{"status": record.status, "attempts": record.attempts}
|
|
201
|
+
for record in self._records.values()]
|
|
202
|
+
with TaskProgress(display_records, self.policy.max_parallel_tasks, self.policy.max_retries,
|
|
203
|
+
label=self.label) as progress:
|
|
204
|
+
try:
|
|
205
|
+
while self._has_work():
|
|
206
|
+
now = time.monotonic()
|
|
207
|
+
devices = self._scan(now)
|
|
208
|
+
if self._finish_tasks(devices, now):
|
|
209
|
+
deadline = now + self.policy.wait_seconds
|
|
210
|
+
self._block_dependents()
|
|
211
|
+
self._deliver_events()
|
|
212
|
+
self._start_tasks(devices, now)
|
|
213
|
+
if self._pending() and not self._running:
|
|
214
|
+
self._check_wait(now, deadline)
|
|
215
|
+
display_records[:] = [{"status": record.status,
|
|
216
|
+
"attempts": record.attempts} for record in self._records.values()]
|
|
217
|
+
self._render(progress)
|
|
218
|
+
if self._has_work():
|
|
219
|
+
time.sleep(1)
|
|
220
|
+
finally:
|
|
221
|
+
self._interrupt_running()
|
|
222
|
+
self._save()
|
|
223
|
+
self._deliver_events()
|
|
224
|
+
return self.tasks
|
|
225
|
+
|
|
226
|
+
@staticmethod
|
|
227
|
+
def _validate_graph(specs: dict[str, TaskSpec]) -> None:
|
|
228
|
+
remaining = {identifier: len(spec.dependencies) for identifier, spec in specs.items()}
|
|
229
|
+
dependents = {identifier: [] for identifier in specs}
|
|
230
|
+
for identifier, spec in specs.items():
|
|
231
|
+
for dependency in spec.dependencies:
|
|
232
|
+
if dependency not in specs:
|
|
233
|
+
raise ValueError(f"Unknown dependency: {dependency}")
|
|
234
|
+
dependents[dependency].append(identifier)
|
|
235
|
+
ready = deque(identifier for identifier, count in remaining.items() if count == 0)
|
|
236
|
+
visited = 0
|
|
237
|
+
while ready:
|
|
238
|
+
identifier = ready.popleft()
|
|
239
|
+
visited += 1
|
|
240
|
+
for dependent in dependents[identifier]:
|
|
241
|
+
remaining[dependent] -= 1
|
|
242
|
+
if remaining[dependent] == 0:
|
|
243
|
+
ready.append(dependent)
|
|
244
|
+
if visited != len(specs):
|
|
245
|
+
raise ValueError("Task dependency cycle")
|
|
246
|
+
|
|
247
|
+
def _require_session(self) -> None:
|
|
248
|
+
if self._session is None:
|
|
249
|
+
raise RuntimeError("Open a scheduler session before recovering tasks")
|
|
250
|
+
|
|
251
|
+
def _save(self) -> None:
|
|
252
|
+
if self._session is None:
|
|
253
|
+
return
|
|
254
|
+
self.store.save(self._records)
|
|
255
|
+
for observer in self._observers:
|
|
256
|
+
observer(self.tasks)
|
|
257
|
+
|
|
258
|
+
def _handler(self, record: TaskRecord) -> TaskHandler:
|
|
259
|
+
if record.spec.handler not in self._handlers:
|
|
260
|
+
raise ValueError(f"Handler is not registered: {record.spec.handler}")
|
|
261
|
+
return self._handlers[record.spec.handler]
|
|
262
|
+
|
|
263
|
+
def _context(self, record: TaskRecord) -> TaskContext:
|
|
264
|
+
return TaskContext(deepcopy(record.spec), self.root, record.status, tuple(deepcopy(record.attempts)))
|
|
265
|
+
|
|
266
|
+
def _has_work(self) -> bool:
|
|
267
|
+
return bool(self._running) or any(record.status in {"pending", "committing"} for record in self._records.values())
|
|
268
|
+
|
|
269
|
+
def _pending(self) -> list[TaskRecord]:
|
|
270
|
+
return [record for record in self._records.values() if record.status == "pending"]
|
|
271
|
+
|
|
272
|
+
def _ready(self) -> list[TaskRecord]:
|
|
273
|
+
return sorted((record for record in self._pending()
|
|
274
|
+
if all(self._records[dependency].status == "complete" for dependency in record.spec.dependencies)),
|
|
275
|
+
key=lambda record: (-record.spec.priority, -record.spec.resources.memory_mib, record.sequence))
|
|
276
|
+
|
|
277
|
+
def _scan(self, now: float) -> list[GPU] | None:
|
|
278
|
+
if now < self._next_scan or not any(record.spec.resources.device == "cuda" for record in self._records.values()):
|
|
279
|
+
return None
|
|
280
|
+
self._next_scan = now + self.policy.poll_interval_seconds
|
|
281
|
+
try:
|
|
282
|
+
devices = self.runtime.discover()
|
|
283
|
+
self.memory.refresh(devices)
|
|
284
|
+
self._known.update(gpu.uuid for gpu in visible_devices(devices, self.policy))
|
|
285
|
+
self._query_error = ""
|
|
286
|
+
return devices
|
|
287
|
+
except GPUQueryError as error:
|
|
288
|
+
self._query_error = str(error)
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
def _finish_tasks(self, devices: list[GPU] | None, now: float) -> bool:
|
|
292
|
+
finished = False
|
|
293
|
+
for identifier, running in list(self._running.items()):
|
|
294
|
+
if devices is not None and running.gpu is not None:
|
|
295
|
+
running.missing = 0 if any(gpu.uuid == running.gpu.uuid for gpu in devices) else running.missing + 1
|
|
296
|
+
lost = running.missing >= 2
|
|
297
|
+
timeout = self.policy.task_timeout_seconds > 0 and now - running.started > self.policy.task_timeout_seconds
|
|
298
|
+
code = running.process.poll()
|
|
299
|
+
if code is None and not lost and not timeout:
|
|
300
|
+
continue
|
|
301
|
+
self.runtime.stop(running.process)
|
|
302
|
+
self._release(identifier, running)
|
|
303
|
+
record = self._records[identifier]
|
|
304
|
+
if lost or timeout:
|
|
305
|
+
assessment = Assessment(False, True, "device" if lost else "timeout",
|
|
306
|
+
"GPU disappeared" if lost else "task timed out")
|
|
307
|
+
else:
|
|
308
|
+
assessment = self._handler(record).assess(self._context(record), TaskOutcome(code))
|
|
309
|
+
if assessment.success:
|
|
310
|
+
record.status = "committing"
|
|
311
|
+
self._save()
|
|
312
|
+
self._commit(record)
|
|
313
|
+
else:
|
|
314
|
+
self._fail(record, assessment, running.gpu, now)
|
|
315
|
+
finished = True
|
|
316
|
+
return finished
|
|
317
|
+
|
|
318
|
+
def _commit(self, record: TaskRecord) -> None:
|
|
319
|
+
result = self._handler(record).commit(self._context(record))
|
|
320
|
+
json.dumps(result, allow_nan=False)
|
|
321
|
+
if not isinstance(result, dict):
|
|
322
|
+
raise TypeError("Task commit must return a JSON object")
|
|
323
|
+
record.result = deepcopy(result)
|
|
324
|
+
record.status = "complete"
|
|
325
|
+
if record.attempts:
|
|
326
|
+
record.attempts[-1].status = "complete"
|
|
327
|
+
self._event = f"{record.spec.name} completed"
|
|
328
|
+
self._save()
|
|
329
|
+
|
|
330
|
+
def _fail(self, record: TaskRecord, assessment: Assessment, gpu: GPU | None, now: float) -> None:
|
|
331
|
+
attempt = record.attempts[-1]
|
|
332
|
+
attempt.status, attempt.kind, attempt.message = "failed", assessment.kind, assessment.message
|
|
333
|
+
attempt.retryable = assessment.retryable
|
|
334
|
+
retry = record.can_retry(self.policy.max_retries)
|
|
335
|
+
record.status = "pending" if retry else "failed"
|
|
336
|
+
if gpu is not None:
|
|
337
|
+
if assessment.kind == "device":
|
|
338
|
+
self._quarantined.add(gpu.uuid)
|
|
339
|
+
else:
|
|
340
|
+
self._cooldown[gpu.uuid] = now + self.policy.gpu_cooldown_seconds
|
|
341
|
+
self._event = f"{record.spec.name}: {assessment.message}; {'retry queued' if retry else 'failed'}"
|
|
342
|
+
self._save()
|
|
343
|
+
|
|
344
|
+
def _acquire_gpu(self, record: TaskRecord, now: float) -> tuple[GPU | None, TextIO | None]:
|
|
345
|
+
last_gpu = record.attempts[-1].gpu if record.attempts else None
|
|
346
|
+
for gpu in self.memory.candidates(record.spec.resources.memory_mib, self._cooldown,
|
|
347
|
+
self._quarantined, now, last_gpu):
|
|
348
|
+
path = Path(tempfile.gettempdir()) / f"{self.lock_namespace}-{os.getuid()}-{gpu.uuid}.lock"
|
|
349
|
+
handle = path.open("a")
|
|
350
|
+
try:
|
|
351
|
+
fcntl.flock(handle, (fcntl.LOCK_EX if self.policy.require_idle else fcntl.LOCK_SH) | fcntl.LOCK_NB)
|
|
352
|
+
except BlockingIOError:
|
|
353
|
+
handle.close()
|
|
354
|
+
continue
|
|
355
|
+
self.memory.reserve(record.sequence, gpu, record.spec.resources.memory_mib)
|
|
356
|
+
return gpu, handle
|
|
357
|
+
return None, None
|
|
358
|
+
|
|
359
|
+
def _start_tasks(self, devices: list[GPU] | None, now: float) -> None:
|
|
360
|
+
for record in self._ready():
|
|
361
|
+
if len(self._running) >= self.policy.max_parallel_tasks:
|
|
362
|
+
break
|
|
363
|
+
gpu, lock = None, None
|
|
364
|
+
if record.spec.resources.device == "cuda":
|
|
365
|
+
if devices is None:
|
|
366
|
+
continue
|
|
367
|
+
gpu, lock = self._acquire_gpu(record, now)
|
|
368
|
+
if gpu is None:
|
|
369
|
+
continue
|
|
370
|
+
self._launch(record, gpu, lock, now)
|
|
371
|
+
|
|
372
|
+
def _launch(self, record: TaskRecord, gpu: GPU | None, lock: TextIO | None, now: float) -> None:
|
|
373
|
+
name = re.sub(r"[^A-Za-z0-9_.-]", "_", record.spec.name)[:80]
|
|
374
|
+
directory = self.root / f"{record.sequence:03d}-{name}" / f"{len(record.attempts) + 1:04d}"
|
|
375
|
+
process = None
|
|
376
|
+
try:
|
|
377
|
+
record.status = "running"
|
|
378
|
+
record.attempts.append(Attempt(str(directory), gpu=None if gpu is None else gpu.uuid,
|
|
379
|
+
memory_mib=record.spec.resources.memory_mib))
|
|
380
|
+
self._save()
|
|
381
|
+
directory.mkdir(parents=True)
|
|
382
|
+
command = self._handler(record).prepare(self._context(record))
|
|
383
|
+
inherited = (*self.inherited_fds, self._lock_fd)
|
|
384
|
+
if lock is not None:
|
|
385
|
+
inherited = (*inherited, lock.fileno())
|
|
386
|
+
process = self.runtime.launch(command, directory, gpu, inherited)
|
|
387
|
+
record.attempts[-1].pid = process.pid
|
|
388
|
+
if gpu is not None:
|
|
389
|
+
self.memory.started(record.sequence, process.pid)
|
|
390
|
+
self._running[record.spec.id] = RunningTask(process, gpu, lock, directory, now)
|
|
391
|
+
except BaseException:
|
|
392
|
+
if process is not None:
|
|
393
|
+
self.runtime.stop(process)
|
|
394
|
+
self.memory.release(record.sequence)
|
|
395
|
+
if lock is not None:
|
|
396
|
+
lock.close()
|
|
397
|
+
record.status = "pending"
|
|
398
|
+
record.attempts[-1].status = "interrupted"
|
|
399
|
+
raise
|
|
400
|
+
self._event = (f"{record.spec.name} started on {'CPU' if gpu is None else 'GPU' + str(gpu.index)} "
|
|
401
|
+
f"PID{process.pid} budget {record.spec.resources.memory_mib / 1024:g}GiB")
|
|
402
|
+
self._save()
|
|
403
|
+
|
|
404
|
+
def _release(self, identifier: str, running: RunningTask) -> None:
|
|
405
|
+
self.memory.release(self._records[identifier].sequence)
|
|
406
|
+
if running.lock is not None:
|
|
407
|
+
running.lock.close()
|
|
408
|
+
del self._running[identifier]
|
|
409
|
+
|
|
410
|
+
def _interrupt_running(self) -> None:
|
|
411
|
+
for identifier, running in list(self._running.items()):
|
|
412
|
+
self.runtime.stop(running.process)
|
|
413
|
+
self._release(identifier, running)
|
|
414
|
+
record = self._records[identifier]
|
|
415
|
+
record.status = "pending"
|
|
416
|
+
record.attempts[-1].status = "interrupted"
|
|
417
|
+
|
|
418
|
+
def _block_dependents(self) -> None:
|
|
419
|
+
changed = True
|
|
420
|
+
while changed:
|
|
421
|
+
changed = False
|
|
422
|
+
for record in self._pending():
|
|
423
|
+
if any(self._records[dependency].status in {"failed", "blocked"} for dependency in record.spec.dependencies):
|
|
424
|
+
record.status = "blocked"
|
|
425
|
+
changed = True
|
|
426
|
+
self._save()
|
|
427
|
+
|
|
428
|
+
def _deliver_events(self) -> None:
|
|
429
|
+
for record in list(self._records.values()):
|
|
430
|
+
if record.status not in {"complete", "failed", "blocked"}:
|
|
431
|
+
continue
|
|
432
|
+
event_id = f"{record.spec.id}:{len(record.attempts)}:{record.status}"
|
|
433
|
+
for name, listener in self._listeners.items():
|
|
434
|
+
if record.acknowledged.get(name) == event_id:
|
|
435
|
+
continue
|
|
436
|
+
listener(TaskEvent(event_id, deepcopy(record.spec), record.status,
|
|
437
|
+
tuple(deepcopy(record.attempts)), deepcopy(record.result)))
|
|
438
|
+
record.acknowledged[name] = event_id
|
|
439
|
+
self._save()
|
|
440
|
+
|
|
441
|
+
def _check_wait(self, now: float, deadline: float) -> None:
|
|
442
|
+
self._event = "Waiting for eligible GPUs" + (f": {self._query_error}" if self._query_error else "")
|
|
443
|
+
if self._known and self._known <= self._quarantined:
|
|
444
|
+
raise RuntimeError("All allowed GPUs quarantined; repair GPUs before rerunning")
|
|
445
|
+
if now > deadline:
|
|
446
|
+
raise TimeoutError("No eligible GPU became available before timeout")
|
|
447
|
+
|
|
448
|
+
def _render(self, progress: TaskProgress) -> None:
|
|
449
|
+
workers = []
|
|
450
|
+
for identifier, running in self._running.items():
|
|
451
|
+
state = {"current": 0, "total": 0, "phase": "initializing", "unit": "step"}
|
|
452
|
+
try:
|
|
453
|
+
state.update(json.loads((running.directory / "progress.json").read_text()))
|
|
454
|
+
except (OSError, ValueError):
|
|
455
|
+
pass
|
|
456
|
+
record = self._records[identifier]
|
|
457
|
+
workers.append(WorkerStatus(record.sequence, running.process.pid,
|
|
458
|
+
"CPU" if running.gpu is None else f"GPU{running.gpu.index}", len(record.attempts),
|
|
459
|
+
state["current"], state["total"], f"{record.spec.name} {state['phase']}", running.started,
|
|
460
|
+
state["unit"], record.spec.resources.memory_mib))
|
|
461
|
+
progress.render(workers, f"{self.memory.summary()}; {self._event}" if self.memory.devices else self._event)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from contextlib import contextmanager
|
|
2
|
+
from dataclasses import asdict
|
|
3
|
+
import fcntl
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .task import TaskRecord
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def atomic_json(path: str | Path, value: object) -> None:
|
|
11
|
+
path = Path(path)
|
|
12
|
+
temporary = path.with_suffix(".tmp")
|
|
13
|
+
temporary.write_text(json.dumps(value, indent=2, allow_nan=False))
|
|
14
|
+
temporary.replace(path)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class StateStore:
|
|
18
|
+
def __init__(self, root: Path):
|
|
19
|
+
self.root = root
|
|
20
|
+
self.path = root / "scheduler.json"
|
|
21
|
+
|
|
22
|
+
@contextmanager
|
|
23
|
+
def lock(self):
|
|
24
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
with (self.root / "scheduler.lock").open("a") as handle:
|
|
26
|
+
try:
|
|
27
|
+
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
28
|
+
except BlockingIOError:
|
|
29
|
+
raise RuntimeError(f"Scheduler is still running: {self.root}") from None
|
|
30
|
+
yield handle.fileno()
|
|
31
|
+
|
|
32
|
+
def load(self) -> dict[str, TaskRecord]:
|
|
33
|
+
if not self.path.exists():
|
|
34
|
+
return {}
|
|
35
|
+
document = json.loads(self.path.read_text())
|
|
36
|
+
if document["version"] != 1:
|
|
37
|
+
raise ValueError("Unsupported scheduler state version")
|
|
38
|
+
records = [TaskRecord.from_dict(record) for record in document["tasks"]]
|
|
39
|
+
if len({record.spec.id for record in records}) != len(records):
|
|
40
|
+
raise ValueError("Duplicate task ids in scheduler state")
|
|
41
|
+
if [record.sequence for record in records] != list(range(len(records))):
|
|
42
|
+
raise ValueError("Invalid task sequence numbers in scheduler state")
|
|
43
|
+
if any(record.status not in {"pending", "running", "committing", "complete", "failed", "blocked"}
|
|
44
|
+
for record in records):
|
|
45
|
+
raise ValueError("Unknown task status in scheduler state")
|
|
46
|
+
return {record.spec.id: record for record in records}
|
|
47
|
+
|
|
48
|
+
def save(self, records: dict[str, TaskRecord]) -> None:
|
|
49
|
+
atomic_json(self.path, {"version": 1, "tasks": [asdict(record) for record in records.values()]})
|
task_scheduling/task.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Literal, Protocol
|
|
5
|
+
|
|
6
|
+
from .policy import integer
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
TaskStatus = Literal["pending", "running", "committing", "complete", "failed", "blocked"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class ResourceRequest:
|
|
14
|
+
device: Literal["cpu", "cuda"] = "cpu"
|
|
15
|
+
memory_mib: int = 0
|
|
16
|
+
|
|
17
|
+
def __post_init__(self) -> None:
|
|
18
|
+
if self.device not in {"cpu", "cuda"}:
|
|
19
|
+
raise ValueError("Task devices must be cpu or cuda")
|
|
20
|
+
integer(self.memory_mib, "memory_mib")
|
|
21
|
+
if self.device == "cpu" and self.memory_mib:
|
|
22
|
+
raise ValueError("CPU tasks cannot reserve GPU memory")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class TaskSpec:
|
|
27
|
+
id: str
|
|
28
|
+
handler: str
|
|
29
|
+
name: str = "task"
|
|
30
|
+
payload: dict = field(default_factory=dict)
|
|
31
|
+
dependencies: tuple[str, ...] = ()
|
|
32
|
+
priority: int = 0
|
|
33
|
+
resources: ResourceRequest = field(default_factory=ResourceRequest)
|
|
34
|
+
|
|
35
|
+
def __post_init__(self) -> None:
|
|
36
|
+
if any(not isinstance(value, str) or not value.strip() for value in (self.id, self.handler, self.name)):
|
|
37
|
+
raise ValueError("Task id, handler and name must be nonempty strings")
|
|
38
|
+
if type(self.priority) is not int:
|
|
39
|
+
raise ValueError("Task priority must be an integer")
|
|
40
|
+
if not isinstance(self.dependencies, (list, tuple)) or any(
|
|
41
|
+
not isinstance(value, str) or not value for value in self.dependencies
|
|
42
|
+
):
|
|
43
|
+
raise ValueError("Task dependencies must contain task ids")
|
|
44
|
+
if self.id in self.dependencies or len(set(self.dependencies)) != len(self.dependencies):
|
|
45
|
+
raise ValueError("Task dependencies must be unique and cannot reference the task itself")
|
|
46
|
+
object.__setattr__(self, "dependencies", tuple(self.dependencies))
|
|
47
|
+
if not isinstance(self.payload, dict):
|
|
48
|
+
raise ValueError("Task payload must be a JSON object")
|
|
49
|
+
if not isinstance(self.resources, ResourceRequest):
|
|
50
|
+
raise TypeError("Task resources must be a ResourceRequest")
|
|
51
|
+
json.dumps(self.payload, allow_nan=False)
|
|
52
|
+
|
|
53
|
+
def identity(self) -> dict:
|
|
54
|
+
return {"id": self.id, "handler": self.handler, "payload": self.payload,
|
|
55
|
+
"dependencies": list(self.dependencies)}
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def from_dict(cls, values: dict) -> "TaskSpec":
|
|
59
|
+
return cls(**{**values, "resources": ResourceRequest(**values["resources"])})
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class Command:
|
|
64
|
+
args: tuple[str, ...]
|
|
65
|
+
cwd: str | Path | None = None
|
|
66
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
67
|
+
|
|
68
|
+
def __post_init__(self) -> None:
|
|
69
|
+
if not isinstance(self.args, (list, tuple)) or not self.args or any(
|
|
70
|
+
not isinstance(value, str) for value in self.args
|
|
71
|
+
):
|
|
72
|
+
raise ValueError("Command args must be a nonempty list of strings")
|
|
73
|
+
if any(not isinstance(key, str) or not isinstance(value, str) for key, value in self.env.items()):
|
|
74
|
+
raise ValueError("Command environment must map strings to strings")
|
|
75
|
+
object.__setattr__(self, "args", tuple(self.args))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class Attempt:
|
|
80
|
+
directory: str
|
|
81
|
+
status: str = "running"
|
|
82
|
+
gpu: str | None = None
|
|
83
|
+
pid: int | None = None
|
|
84
|
+
memory_mib: int = 0
|
|
85
|
+
kind: str = ""
|
|
86
|
+
message: str = ""
|
|
87
|
+
retryable: bool = False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class TaskRecord:
|
|
92
|
+
spec: TaskSpec
|
|
93
|
+
sequence: int
|
|
94
|
+
status: TaskStatus = "pending"
|
|
95
|
+
attempts: list[Attempt] = field(default_factory=list)
|
|
96
|
+
result: dict = field(default_factory=dict)
|
|
97
|
+
acknowledged: dict[str, str] = field(default_factory=dict)
|
|
98
|
+
|
|
99
|
+
def can_retry(self, max_retries: int) -> bool:
|
|
100
|
+
failures = [attempt for attempt in self.attempts if attempt.status == "failed"]
|
|
101
|
+
return all(attempt.retryable for attempt in failures) and len(failures) <= max_retries
|
|
102
|
+
|
|
103
|
+
@classmethod
|
|
104
|
+
def from_dict(cls, values: dict) -> "TaskRecord":
|
|
105
|
+
return cls(**{**values, "spec": TaskSpec.from_dict(values["spec"]),
|
|
106
|
+
"attempts": [Attempt(**attempt) for attempt in values["attempts"]]})
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class TaskContext:
|
|
111
|
+
task: TaskSpec
|
|
112
|
+
root: Path
|
|
113
|
+
status: TaskStatus
|
|
114
|
+
attempts: tuple[Attempt, ...]
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def directory(self) -> Path:
|
|
118
|
+
return Path(self.attempts[-1].directory) if self.attempts else self.root
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def gpu(self) -> str | None:
|
|
122
|
+
return self.attempts[-1].gpu if self.attempts else None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True)
|
|
126
|
+
class TaskOutcome:
|
|
127
|
+
returncode: int | None
|
|
128
|
+
timed_out: bool = False
|
|
129
|
+
device_lost: bool = False
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True)
|
|
133
|
+
class Assessment:
|
|
134
|
+
success: bool
|
|
135
|
+
retryable: bool = False
|
|
136
|
+
kind: str = "process"
|
|
137
|
+
message: str = ""
|
|
138
|
+
|
|
139
|
+
def __post_init__(self) -> None:
|
|
140
|
+
if type(self.success) is not bool or type(self.retryable) is not bool:
|
|
141
|
+
raise TypeError("Assessment success and retryable must be booleans")
|
|
142
|
+
if not isinstance(self.kind, str) or not isinstance(self.message, str):
|
|
143
|
+
raise TypeError("Assessment kind and message must be strings")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True)
|
|
147
|
+
class Recovery:
|
|
148
|
+
status: Literal["complete", "failed", "committing"]
|
|
149
|
+
result: dict = field(default_factory=dict)
|
|
150
|
+
|
|
151
|
+
def __post_init__(self) -> None:
|
|
152
|
+
if self.status not in {"complete", "failed", "committing"}:
|
|
153
|
+
raise ValueError("Recovery status must be complete, failed or committing")
|
|
154
|
+
if not isinstance(self.result, dict):
|
|
155
|
+
raise TypeError("Recovery result must be a JSON object")
|
|
156
|
+
json.dumps(self.result, allow_nan=False)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True)
|
|
160
|
+
class TaskEvent:
|
|
161
|
+
id: str
|
|
162
|
+
task: TaskSpec
|
|
163
|
+
status: TaskStatus
|
|
164
|
+
attempts: tuple[Attempt, ...]
|
|
165
|
+
result: dict
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class TaskHandler(Protocol):
|
|
169
|
+
def prepare(self, context: TaskContext) -> Command: ...
|
|
170
|
+
|
|
171
|
+
def assess(self, context: TaskContext, outcome: TaskOutcome) -> Assessment: ...
|
|
172
|
+
|
|
173
|
+
def commit(self, context: TaskContext) -> dict: ...
|
|
174
|
+
|
|
175
|
+
def recover(self, context: TaskContext) -> Recovery | None: ...
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class CommandHandler:
|
|
179
|
+
def prepare(self, context: TaskContext) -> Command:
|
|
180
|
+
return Command(**context.task.payload)
|
|
181
|
+
|
|
182
|
+
def assess(self, context: TaskContext, outcome: TaskOutcome) -> Assessment:
|
|
183
|
+
if outcome.device_lost:
|
|
184
|
+
return Assessment(False, True, "device", "GPU disappeared")
|
|
185
|
+
if outcome.timed_out:
|
|
186
|
+
return Assessment(False, True, "timeout", "task timed out")
|
|
187
|
+
return Assessment(outcome.returncode == 0, True, "process", f"worker exited {outcome.returncode}")
|
|
188
|
+
|
|
189
|
+
def commit(self, context: TaskContext) -> dict:
|
|
190
|
+
return {}
|
|
191
|
+
|
|
192
|
+
def recover(self, context: TaskContext) -> Recovery | None:
|
|
193
|
+
if context.status == "committing":
|
|
194
|
+
return Recovery("committing")
|
|
195
|
+
if context.status == "complete":
|
|
196
|
+
return Recovery("complete")
|
|
197
|
+
return None
|