mirror of
https://github.com/elder-plinius/OBLITERATUS.git
synced 2026-08-29 22:20:36 +02:00
170 lines
5.9 KiB
Python
170 lines
5.9 KiB
Python
"""Local, credential-free GPU lifecycle publication for host supervisors."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import atexit
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import threading
|
|
import uuid
|
|
from typing import Callable
|
|
|
|
|
|
SCHEMA_VERSION = 1
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MemoryUsage:
|
|
"""Process-visible accelerator memory in bytes."""
|
|
|
|
allocated_bytes: int = 0
|
|
reserved_bytes: int = 0
|
|
device_count: int = 0
|
|
|
|
|
|
class GpuLifecyclePublisher:
|
|
"""Publish ordered lifecycle events into an operator-owned runtime directory."""
|
|
|
|
def __init__(
|
|
self,
|
|
runtime_dir: str | Path | None,
|
|
*,
|
|
heartbeat_seconds: float = 15.0,
|
|
clock: Callable[[], datetime] | None = None,
|
|
run_id: str | None = None,
|
|
) -> None:
|
|
configured_dir = Path(runtime_dir) if runtime_dir else None
|
|
if configured_dir is not None and configured_dir.is_symlink():
|
|
raise ValueError("GPU lifecycle runtime directory must not be a symlink")
|
|
self._dir = configured_dir.resolve() if configured_dir else None
|
|
self._heartbeat_seconds = max(1.0, float(heartbeat_seconds))
|
|
self._clock = clock or (lambda: datetime.now(timezone.utc))
|
|
self._run_id = run_id or str(uuid.uuid4())
|
|
self._lock = threading.RLock()
|
|
self._sequence = 0
|
|
self._model_id: str | None = None
|
|
self._phase = "released"
|
|
self._last_memory = MemoryUsage()
|
|
self._stop = threading.Event()
|
|
self._thread: threading.Thread | None = None
|
|
if self._dir is not None:
|
|
if not self._dir.is_dir():
|
|
raise ValueError("GPU lifecycle runtime directory must already exist")
|
|
atexit.register(self.release, reason="process_exit")
|
|
|
|
@property
|
|
def enabled(self) -> bool:
|
|
return self._dir is not None
|
|
|
|
def loading(self, model_id: str) -> dict | None:
|
|
with self._lock:
|
|
self._model_id = str(model_id)
|
|
self._phase = "loading"
|
|
return self._publish("loading")
|
|
|
|
def resize(self, memory: MemoryUsage) -> dict | None:
|
|
with self._lock:
|
|
self._last_memory = memory
|
|
return self._publish("resize")
|
|
|
|
def ready(self, memory: MemoryUsage | None = None) -> dict | None:
|
|
with self._lock:
|
|
if memory is not None:
|
|
self._last_memory = memory
|
|
self._phase = "ready"
|
|
event = self._publish("ready")
|
|
self._start_heartbeat()
|
|
return event
|
|
|
|
def heartbeat(self) -> dict | None:
|
|
with self._lock:
|
|
if self._phase != "ready":
|
|
return None
|
|
return self._publish("heartbeat")
|
|
|
|
def release(self, *, reason: str = "unload") -> dict | None:
|
|
with self._lock:
|
|
if self._phase == "released":
|
|
return None
|
|
self._phase = "released"
|
|
self._stop.set()
|
|
event = self._publish("release", reason=reason)
|
|
self._model_id = None
|
|
self._last_memory = MemoryUsage()
|
|
return event
|
|
|
|
def _start_heartbeat(self) -> None:
|
|
if not self.enabled or (self._thread is not None and self._thread.is_alive()):
|
|
return
|
|
self._stop.clear()
|
|
self._thread = threading.Thread(
|
|
target=self._heartbeat_loop,
|
|
name="obliteratus-gpu-heartbeat",
|
|
daemon=True,
|
|
)
|
|
self._thread.start()
|
|
|
|
def _heartbeat_loop(self) -> None:
|
|
while not self._stop.wait(self._heartbeat_seconds):
|
|
self.heartbeat()
|
|
|
|
def _publish(self, event: str, *, reason: str | None = None) -> dict | None:
|
|
if not self.enabled:
|
|
return None
|
|
self._sequence += 1
|
|
payload = {
|
|
"schema_version": SCHEMA_VERSION,
|
|
"event_id": f"{self._run_id}:{self._sequence}",
|
|
"sequence": self._sequence,
|
|
"event": event,
|
|
"phase": self._phase,
|
|
"run_id": self._run_id,
|
|
"model_id": self._model_id,
|
|
"pid": os.getpid(),
|
|
"timestamp": self._clock().isoformat(),
|
|
"allocated_vram_bytes": self._last_memory.allocated_bytes,
|
|
"reserved_vram_bytes": self._last_memory.reserved_bytes,
|
|
"device_count": self._last_memory.device_count,
|
|
}
|
|
if reason is not None:
|
|
payload["reason"] = reason
|
|
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
|
events = self._dir / "events.jsonl" # type: ignore[operator]
|
|
current = self._dir / "current.json" # type: ignore[operator]
|
|
temporary = self._dir / f".current.{os.getpid()}.tmp" # type: ignore[operator]
|
|
with events.open("a", encoding="utf-8") as stream:
|
|
stream.write(encoded + "\n")
|
|
stream.flush()
|
|
temporary.write_text(encoded + "\n", encoding="utf-8")
|
|
os.replace(temporary, current)
|
|
return payload
|
|
|
|
|
|
def from_environment() -> GpuLifecyclePublisher:
|
|
"""Build the process publisher; an unset directory yields a no-op publisher."""
|
|
interval = os.environ.get("OBLITERATUS_GPU_HEARTBEAT_SECONDS", "15")
|
|
try:
|
|
heartbeat_seconds = float(interval)
|
|
except ValueError:
|
|
heartbeat_seconds = 15.0
|
|
return GpuLifecyclePublisher(
|
|
os.environ.get("OBLITERATUS_GPU_LIFECYCLE_DIR"),
|
|
heartbeat_seconds=heartbeat_seconds,
|
|
)
|
|
|
|
|
|
def measure_torch_memory(torch_module) -> MemoryUsage:
|
|
"""Measure this process' CUDA allocator without initializing CUDA on CPU hosts."""
|
|
cuda = getattr(torch_module, "cuda", None)
|
|
if cuda is None or not cuda.is_available():
|
|
return MemoryUsage()
|
|
count = cuda.device_count()
|
|
return MemoryUsage(
|
|
allocated_bytes=sum(int(cuda.memory_allocated(index)) for index in range(count)),
|
|
reserved_bytes=sum(int(cuda.memory_reserved(index)) for index in range(count)),
|
|
device_count=count,
|
|
)
|