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 +3 -0
- bk/__main__.py +6 -0
- bk/advisor.py +119 -0
- bk/allocator.py +264 -0
- bk/cli.py +1259 -0
- bk/config.py +233 -0
- bk/data/codex-skill/gpubk/SKILL.md +76 -0
- bk/data/codex-skill/gpubk/agents/openai.yaml +4 -0
- bk/data/codex-skill/gpubk/references/protocol.md +61 -0
- bk/data/systemd/bk-monitor.service +16 -0
- bk/data/systemd/bk-worker.service +16 -0
- bk/fileio.py +65 -0
- bk/gpu.py +347 -0
- bk/identity.py +16 -0
- bk/mcp_server.py +370 -0
- bk/models.py +79 -0
- bk/monitor.py +658 -0
- bk/policy.py +62 -0
- bk/schedule_index.py +78 -0
- bk/scheduler.py +1315 -0
- bk/service.py +570 -0
- bk/skill.py +67 -0
- bk/storage.py +461 -0
- bk/systemd.py +60 -0
- bk/timeparse.py +88 -0
- bk/tui.py +2190 -0
- bk/usage.py +306 -0
- bk/worker.py +768 -0
- gpubk-0.1.0.dist-info/METADATA +298 -0
- gpubk-0.1.0.dist-info/RECORD +34 -0
- gpubk-0.1.0.dist-info/WHEEL +5 -0
- gpubk-0.1.0.dist-info/entry_points.txt +3 -0
- gpubk-0.1.0.dist-info/licenses/LICENSE +201 -0
- gpubk-0.1.0.dist-info/top_level.txt +1 -0
bk/__init__.py
ADDED
bk/__main__.py
ADDED
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
|