dashboard/app/plugins/lact.py

158 lines
5.2 KiB
Python

import asyncio
import re
import time
from typing import Any
from fastapi import APIRouter, Form
from fastapi.responses import HTMLResponse
from app.collect.gpu import _shorten
from app.plugins.base import Plugin
from app.render import render
router = APIRouter(prefix="/api/plugins/lact", tags=["plugins"])
LIST_TIMEOUT = 5
SET_TIMEOUT = 15
GPU_CACHE_TTL = 60
_gpu_cache: tuple[float, list[dict[str, str]]] | None = None
_set_lock = asyncio.Lock()
async def _run(args: list[str], timeout: float) -> tuple[str, str]:
try:
proc = await asyncio.create_subprocess_exec(
"lact", "cli", *args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except FileNotFoundError:
return "", "lact not found in PATH"
except OSError as e:
return "", str(e)[:200]
try:
out, err = await asyncio.wait_for(proc.communicate(), timeout)
except TimeoutError:
try:
proc.kill()
except ProcessLookupError:
pass
return "", "lact timed out"
if proc.returncode != 0:
return "", (err.decode(errors="replace").strip() or f"lact failed (rc={proc.returncode})")[:200]
return out.decode(errors="replace"), ""
def _parse_gpus(out: str) -> list[dict[str, str]]:
gpus: list[dict[str, str]] = []
for line in out.splitlines():
m = re.match(r"^\s*(\d+):\s+(.*)$", line)
if not m:
continue
rest = m.group(2)
name = re.search(r"\(([^)]*)\)", rest)
gpu_type = re.search(r"\[([^\]]*)\]\s*$", rest)
gpus.append({
"id": m.group(1),
"name": _shorten(name.group(1)) if name else rest.strip(),
"type": gpu_type.group(1) if gpu_type else "",
})
return gpus
async def _gpus(force: bool = False) -> tuple[list[dict[str, str]], str]:
global _gpu_cache
if not force and _gpu_cache is not None:
ts, cached = _gpu_cache
if time.monotonic() - ts < GPU_CACHE_TTL:
return [dict(g) for g in cached], ""
out, err = await _run(["list"], LIST_TIMEOUT)
if err:
return [], err
gpus = _parse_gpus(out)
_gpu_cache = (time.monotonic(), gpus)
return gpus, ""
async def _gpu_entry(g: dict[str, str], with_profiles: bool) -> dict[str, Any]:
entry: dict[str, Any] = {**g, "profiles": [], "active": None, "error": ""}
base = ["--gpu-id", g["id"], "profile"]
if with_profiles:
active_p, profiles_p = await asyncio.gather(
_run([*base, "get"], LIST_TIMEOUT),
_run([*base, "list"], LIST_TIMEOUT),
)
else:
active_p, profiles_p = await _run([*base, "get"], LIST_TIMEOUT), None
out, err = active_p
if err:
entry["error"] = err
return entry
entry["active"] = out.strip() or None
if profiles_p is not None:
out2, err2 = profiles_p
entry["error"] = err2
if not err2:
entry["profiles"] = [line.strip() for line in out2.splitlines() if line.strip()]
return entry
async def _gather(with_profiles: bool, force_gpus: bool = False) -> dict[str, Any]:
gpus, err = await _gpus(force=force_gpus)
if err:
return {"gpus": [], "error": err}
entries = await asyncio.gather(*[_gpu_entry(g, with_profiles) for g in gpus])
return {"gpus": list(entries), "error": ""}
async def _state(message: str = "", error: str = "") -> str:
data = await _gather(with_profiles=False)
data["message"] = message
data["error"] = error or data["error"]
return render("plugins/lact_state.html", **data)
async def _skeleton(message: str = "", error: str = "") -> str:
data = await _gather(with_profiles=True, force_gpus=True)
data["message"] = message
data["error"] = error or data["error"]
return render("plugins/lact_skeleton.html", **data)
@router.get("/state")
async def state():
return HTMLResponse(await _state())
@router.post("/set")
async def set_profile(gpu_id: str = Form(...), profile: str = Form(...)):
async with _set_lock:
data = await _gather(with_profiles=True, force_gpus=True)
if data["error"]:
return HTMLResponse(await _skeleton(error=data["error"]))
gpu = next((g for g in data["gpus"] if g["id"] == gpu_id), None)
if gpu is None:
return HTMLResponse(await _skeleton(error=f"unknown gpu id: {gpu_id}"))
if profile not in gpu["profiles"]:
return HTMLResponse(await _skeleton(error=f"unknown profile: {profile}"))
if profile == gpu["active"]:
return HTMLResponse(await _skeleton(message=f"{gpu['name']}: {profile} already active"))
_out, err = await _run(["--gpu-id", gpu_id, "profile", "set", profile], SET_TIMEOUT)
if err:
return HTMLResponse(await _skeleton(error=err))
return HTMLResponse(await _skeleton(message=f"{gpu['name']}: set profile {profile}"))
@router.post("/reload")
async def reload(gpu_id: str | None = Form(None)):
_ = gpu_id
return HTMLResponse(await _skeleton(message="profiles refreshed"))
plugin = Plugin(
id="lact",
title="GPU power profiles",
description="Active profile per GPU and a profile switcher (lact cli).",
skeleton_fn=_skeleton,
)