gpubk 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.
bk/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """GPUbk booking package."""
2
+
3
+ __version__ = "0.1.0"
bk/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
6
+
bk/advisor.py ADDED
@@ -0,0 +1,119 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from typing import Dict, List, Optional, Sequence
6
+
7
+ from .config import Config
8
+ from .gpu import GpuSnapshot, snapshot
9
+ from .monitor import UsageAuditStore
10
+ from .timeparse import to_iso, utc_now
11
+ from .usage import (
12
+ GpuLiveState,
13
+ HistoricalGpuLoad,
14
+ assess_gpu_live_states,
15
+ combined_gpu_scores,
16
+ gpu_selection_order,
17
+ historical_gpu_loads,
18
+ )
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class GpuAdvice:
23
+ generated_at: datetime
24
+ snapshots: Sequence[GpuSnapshot]
25
+ live_states: Dict[int, GpuLiveState]
26
+ historical_loads: Dict[int, HistoricalGpuLoad]
27
+ scores: Dict[int, float]
28
+ order: List[int]
29
+
30
+ @property
31
+ def memory_capacities_mb(self) -> Dict[int, int]:
32
+ return {
33
+ item.index: item.memory_total_mb
34
+ for item in self.snapshots
35
+ if item.memory_total_mb > 0
36
+ }
37
+
38
+ def as_dict(self) -> dict:
39
+ snapshots = {item.index: item for item in self.snapshots}
40
+ return {
41
+ "generated_at": to_iso(self.generated_at),
42
+ "order": list(self.order),
43
+ "gpus": [
44
+ _gpu_advice_dict(
45
+ index,
46
+ snapshots.get(index),
47
+ self.live_states[index],
48
+ self.historical_loads[index],
49
+ self.scores[index],
50
+ )
51
+ for index in sorted(self.live_states)
52
+ ],
53
+ }
54
+
55
+
56
+ def build_gpu_advice(
57
+ config: Config,
58
+ *,
59
+ snapshots: Optional[Sequence[GpuSnapshot]] = None,
60
+ history: Optional[dict] = None,
61
+ at: Optional[datetime] = None,
62
+ ) -> GpuAdvice:
63
+ generated_at = at or utc_now()
64
+ devices = list(snapshot(config) if snapshots is None else snapshots)
65
+ if history is None:
66
+ history = UsageAuditStore(
67
+ config.data_dir,
68
+ config.lock_timeout_seconds,
69
+ config.file_mode,
70
+ config.dir_mode,
71
+ ).load_load_history()
72
+ live = assess_gpu_live_states(devices, config.gpu_count)
73
+ historical = historical_gpu_loads(history, config.gpu_count, generated_at)
74
+ scores = combined_gpu_scores(live, historical)
75
+ return GpuAdvice(
76
+ generated_at=generated_at,
77
+ snapshots=devices,
78
+ live_states=live,
79
+ historical_loads=historical,
80
+ scores=scores,
81
+ order=gpu_selection_order(live, scores),
82
+ )
83
+
84
+
85
+ def _gpu_advice_dict(
86
+ index: int,
87
+ snapshot_item: Optional[GpuSnapshot],
88
+ live: GpuLiveState,
89
+ historical: HistoricalGpuLoad,
90
+ score: float,
91
+ ) -> dict:
92
+ total = snapshot_item.memory_total_mb if snapshot_item is not None else 0
93
+ used = snapshot_item.memory_used_mb if snapshot_item is not None else 0
94
+ return {
95
+ "index": index,
96
+ "name": snapshot_item.name if snapshot_item is not None else "unknown",
97
+ "temperature_c": snapshot_item.temperature_c if snapshot_item is not None else None,
98
+ "score": score,
99
+ "live": {
100
+ "status": live.status,
101
+ "reason": live.reason,
102
+ "utilization_percent": live.utilization_percent,
103
+ "memory_percent": round(live.memory_percent, 3),
104
+ "process_count": live.process_count,
105
+ },
106
+ "history": {
107
+ "predicted_percent": round(historical.predicted_percent, 3),
108
+ "average_utilization_percent": round(historical.average_utilization_percent, 3),
109
+ "busy_fraction": round(historical.busy_fraction, 4),
110
+ "memory_percent": round(historical.memory_percent, 3),
111
+ "sample_count": historical.sample_count,
112
+ },
113
+ "memory": {
114
+ "used_mb": used or None,
115
+ "total_mb": total or None,
116
+ "free_mb": max(0, total - used) if total else None,
117
+ },
118
+ "source": snapshot_item.source if snapshot_item is not None else "none",
119
+ }
bk/allocator.py ADDED
@@ -0,0 +1,264 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import selectors
6
+ import signal
7
+ import subprocess
8
+ import time
9
+ from dataclasses import dataclass
10
+ from datetime import datetime
11
+ from typing import Dict, List, Optional
12
+
13
+ from .advisor import GpuAdvice
14
+ from .config import Config
15
+ from .models import Actor
16
+ from .scheduler import list_active
17
+ from .storage import LedgerStore
18
+ from .timeparse import to_iso
19
+
20
+
21
+ ALLOCATOR_SCHEMA_VERSION = "bk.allocator.v1"
22
+ MAX_ALLOCATOR_OUTPUT_BYTES = 64 * 1024
23
+ MAX_ALLOCATOR_STDERR_BYTES = 8 * 1024
24
+ ALLOCATOR_IO_CHUNK_BYTES = 64 * 1024
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class AllocatorDecision:
29
+ order: List[int]
30
+ scores: Dict[int, float]
31
+ source: str
32
+ reason: str = ""
33
+ warning: str = ""
34
+
35
+
36
+ def apply_external_allocator(
37
+ config: Config,
38
+ store: LedgerStore,
39
+ actor: Actor,
40
+ advice: GpuAdvice,
41
+ *,
42
+ count: int,
43
+ duration_seconds: int,
44
+ start_at: datetime,
45
+ mode: str,
46
+ expected_memory_mb: Optional[int],
47
+ ) -> AllocatorDecision:
48
+ if not config.allocator_command:
49
+ return AllocatorDecision(list(advice.order), dict(advice.scores), "builtin")
50
+ payload = _allocator_payload(
51
+ config,
52
+ store,
53
+ actor,
54
+ advice,
55
+ count=count,
56
+ duration_seconds=duration_seconds,
57
+ start_at=start_at,
58
+ mode=mode,
59
+ expected_memory_mb=expected_memory_mb,
60
+ )
61
+ try:
62
+ returncode, stdout, stderr = _run_allocator_process(
63
+ list(config.allocator_command),
64
+ json.dumps(payload, ensure_ascii=True, sort_keys=True),
65
+ config.allocator_timeout_seconds,
66
+ )
67
+ if returncode != 0:
68
+ detail = stderr.strip().splitlines()[-1][:200] if stderr.strip() else "no stderr"
69
+ raise ValueError(f"allocator exited {returncode}: {detail}")
70
+ response = json.loads(stdout)
71
+ order, reason = _validate_allocator_response(response, config.gpu_count)
72
+ except (OSError, UnicodeError, ValueError, TypeError, json.JSONDecodeError, subprocess.TimeoutExpired) as exc:
73
+ return AllocatorDecision(
74
+ list(advice.order),
75
+ dict(advice.scores),
76
+ "builtin-fallback",
77
+ warning=f"external allocator ignored: {exc}",
78
+ )
79
+
80
+ adjusted_scores = dict(advice.scores)
81
+ for rank, gpu in enumerate(order):
82
+ adjusted_scores[gpu] = round(adjusted_scores[gpu] + rank * config.allocator_weight, 3)
83
+ return AllocatorDecision(order, adjusted_scores, "external", reason=reason)
84
+
85
+
86
+ def _run_allocator_process(argv: List[str], payload: str, timeout_seconds: float) -> tuple[int, str, str]:
87
+ process = subprocess.Popen(
88
+ argv,
89
+ stdin=subprocess.PIPE,
90
+ stdout=subprocess.PIPE,
91
+ stderr=subprocess.PIPE,
92
+ shell=False,
93
+ start_new_session=True,
94
+ close_fds=True,
95
+ )
96
+ streams = (process.stdin, process.stdout, process.stderr)
97
+ if any(stream is None for stream in streams):
98
+ _kill_allocator_process_group(process)
99
+ raise OSError("allocator subprocess pipes are unavailable")
100
+ selector = selectors.DefaultSelector()
101
+ payload_bytes = payload.encode("utf-8")
102
+ payload_offset = 0
103
+ stdout = bytearray()
104
+ stderr = bytearray()
105
+ deadline = time.monotonic() + timeout_seconds
106
+ try:
107
+ for stream in streams:
108
+ os.set_blocking(stream.fileno(), False)
109
+ selector.register(process.stdin, selectors.EVENT_WRITE, "stdin")
110
+ selector.register(process.stdout, selectors.EVENT_READ, "stdout")
111
+ selector.register(process.stderr, selectors.EVENT_READ, "stderr")
112
+
113
+ while selector.get_map():
114
+ remaining = deadline - time.monotonic()
115
+ if remaining <= 0:
116
+ raise subprocess.TimeoutExpired(argv, timeout_seconds)
117
+ for key, _events in selector.select(min(remaining, 0.1)):
118
+ stream = key.fileobj
119
+ if key.data == "stdin":
120
+ try:
121
+ written = os.write(
122
+ stream.fileno(),
123
+ payload_bytes[payload_offset : payload_offset + ALLOCATOR_IO_CHUNK_BYTES],
124
+ )
125
+ except BrokenPipeError:
126
+ _close_selector_stream(selector, stream)
127
+ continue
128
+ payload_offset += written
129
+ if payload_offset >= len(payload_bytes):
130
+ _close_selector_stream(selector, stream)
131
+ continue
132
+
133
+ try:
134
+ chunk = os.read(stream.fileno(), ALLOCATOR_IO_CHUNK_BYTES)
135
+ except BlockingIOError:
136
+ continue
137
+ if not chunk:
138
+ _close_selector_stream(selector, stream)
139
+ elif key.data == "stdout":
140
+ keep = MAX_ALLOCATOR_OUTPUT_BYTES + 1 - len(stdout)
141
+ if keep > 0:
142
+ stdout.extend(chunk[:keep])
143
+ if len(stdout) > MAX_ALLOCATOR_OUTPUT_BYTES:
144
+ raise ValueError("allocator output exceeded 64 KiB")
145
+ else:
146
+ _append_bounded_tail(stderr, chunk, MAX_ALLOCATOR_STDERR_BYTES)
147
+
148
+ remaining = deadline - time.monotonic()
149
+ if remaining <= 0:
150
+ raise subprocess.TimeoutExpired(argv, timeout_seconds)
151
+ returncode = process.wait(timeout=remaining)
152
+ _kill_allocator_process_group(process)
153
+ return returncode, stdout.decode("utf-8"), stderr.decode("utf-8", errors="replace")
154
+ except Exception:
155
+ _kill_allocator_process_group(process)
156
+ raise
157
+ finally:
158
+ for stream in streams:
159
+ _close_selector_stream(selector, stream)
160
+ selector.close()
161
+
162
+
163
+ def _append_bounded_tail(buffer: bytearray, chunk: bytes, limit: int) -> None:
164
+ if len(chunk) >= limit:
165
+ buffer[:] = chunk[-limit:]
166
+ return
167
+ overflow = len(buffer) + len(chunk) - limit
168
+ if overflow > 0:
169
+ del buffer[:overflow]
170
+ buffer.extend(chunk)
171
+
172
+
173
+ def _close_selector_stream(selector: selectors.BaseSelector, stream) -> None:
174
+ try:
175
+ selector.unregister(stream)
176
+ except (KeyError, ValueError):
177
+ pass
178
+ try:
179
+ stream.close()
180
+ except OSError:
181
+ pass
182
+
183
+
184
+ def _kill_allocator_process_group(process: subprocess.Popen) -> None:
185
+ try:
186
+ os.killpg(process.pid, signal.SIGKILL)
187
+ except (OSError, ProcessLookupError):
188
+ if process.poll() is None:
189
+ try:
190
+ process.kill()
191
+ except OSError:
192
+ pass
193
+ try:
194
+ process.wait(timeout=1)
195
+ except subprocess.TimeoutExpired:
196
+ pass
197
+
198
+
199
+ def _allocator_payload(
200
+ config: Config,
201
+ store: LedgerStore,
202
+ actor: Actor,
203
+ advice: GpuAdvice,
204
+ *,
205
+ count: int,
206
+ duration_seconds: int,
207
+ start_at: datetime,
208
+ mode: str,
209
+ expected_memory_mb: Optional[int],
210
+ ) -> dict:
211
+ reservations = list_active(store.load(), start_at)
212
+ return {
213
+ "schema_version": ALLOCATOR_SCHEMA_VERSION,
214
+ "kind": "allocation_request",
215
+ "generated_at": to_iso(advice.generated_at),
216
+ "actor": {"uid": actor.uid},
217
+ "request": {
218
+ "count": count,
219
+ "duration_seconds": duration_seconds,
220
+ "start_at": to_iso(start_at),
221
+ "mode": mode,
222
+ "expected_memory_mb_per_gpu": expected_memory_mb,
223
+ },
224
+ "policy": {
225
+ "gpu_count": config.gpu_count,
226
+ "max_shared_reservations_per_gpu": config.max_shared_users,
227
+ "shared_memory_reserve_mb": config.shared_memory_reserve_mb,
228
+ "local_score_is_authoritative_for_safety": True,
229
+ "allocator_weight": config.allocator_weight,
230
+ },
231
+ "builtin_advice": advice.as_dict(),
232
+ "reservations": [
233
+ {
234
+ "uid": item.get("uid"),
235
+ "gpus": list(item.get("gpus", [])),
236
+ "mode": item.get("mode"),
237
+ "start_at": item.get("start_at"),
238
+ "end_at": item.get("end_at"),
239
+ "expected_memory_mb_per_gpu": item.get("expected_memory_mb"),
240
+ }
241
+ for item in reservations
242
+ ],
243
+ "response_contract": {
244
+ "schema_version": ALLOCATOR_SCHEMA_VERSION,
245
+ "gpu_order": "permutation of every configured GPU index",
246
+ "reason": "optional privacy-safe text, max 500 chars",
247
+ },
248
+ }
249
+
250
+
251
+ def _validate_allocator_response(response: object, gpu_count: int) -> tuple[List[int], str]:
252
+ if not isinstance(response, dict):
253
+ raise ValueError("allocator response must be a JSON object")
254
+ if response.get("schema_version") != ALLOCATOR_SCHEMA_VERSION:
255
+ raise ValueError("allocator schema version mismatch")
256
+ raw_order = response.get("gpu_order")
257
+ if not isinstance(raw_order, list) or any(isinstance(item, bool) or not isinstance(item, int) for item in raw_order):
258
+ raise ValueError("gpu_order must be an integer array")
259
+ if raw_order != list(dict.fromkeys(raw_order)) or sorted(raw_order) != list(range(gpu_count)):
260
+ raise ValueError("gpu_order must be a permutation of configured GPUs")
261
+ reason = str(response.get("reason", "")).strip()
262
+ if len(reason) > 500 or any(ord(char) < 32 and char not in "\t" for char in reason):
263
+ raise ValueError("allocator reason is invalid")
264
+ return list(raw_order), reason