247 lines
8.2 KiB
Python
247 lines
8.2 KiB
Python
import asyncio
|
|
import re
|
|
import time
|
|
from typing import Annotated, Any
|
|
|
|
from fastapi import APIRouter, Form
|
|
from fastapi.responses import HTMLResponse
|
|
|
|
from app.plugins.base import Plugin
|
|
from app.render import render
|
|
from app.utils.gpu import shorten
|
|
from app.utils.subprocess import run_async
|
|
|
|
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]:
|
|
"""Run `lact cli` with the given arguments, with a timeout.
|
|
|
|
The child is killed on timeout. All failure modes (binary missing,
|
|
other OSError, timeout, non-zero exit) are returned as a short error
|
|
string rather than raised.
|
|
|
|
Args:
|
|
args: lact cli arguments, e.g. ["list"] or ["--gpu-id", "0", "profile", "set", "balanced"].
|
|
timeout: seconds before the child is killed.
|
|
|
|
Returns:
|
|
(stdout, "") on success, else ("", error description).
|
|
"""
|
|
rc, out, err = await run_async(["lact", "cli", *args], timeout=timeout)
|
|
if rc != 0:
|
|
return "", (err.strip() or f"lact failed (rc={rc})")[:200]
|
|
return out, ""
|
|
|
|
|
|
def _parse_gpus(out: str) -> list[dict[str, str]]:
|
|
"""Parse `lact cli list` output into per-GPU entries.
|
|
|
|
Each line looks like "0: <device> (Renoir [Radeon Vega Series / ...])
|
|
[Integrated]"; the parenthesised name is shortened with
|
|
app.utils.gpu.shorten, the trailing bracket is the GPU type.
|
|
Non-matching lines are skipped.
|
|
|
|
Args:
|
|
out: stdout of `lact cli list`.
|
|
|
|
Returns:
|
|
One {id, name, type} dict per GPU.
|
|
"""
|
|
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]:
|
|
"""List the GPUs known to lact, cached for 60 s.
|
|
|
|
Args:
|
|
force: bypass the cache and re-run `lact cli list`.
|
|
|
|
Returns:
|
|
(copies of the GPU entries, "") on success, else ([], error).
|
|
"""
|
|
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]:
|
|
"""Fetch the active profile (and optionally all profiles) for one GPU.
|
|
|
|
The `profile get` and `profile list` calls run concurrently when
|
|
with_profiles is set, so a full skeleton render only costs one
|
|
round trip of lact calls per GPU. A `get` failure is reported in the
|
|
entry's error field and skips the profile list.
|
|
|
|
Args:
|
|
g: GPU entry from _gpus() ({id, name, type}).
|
|
with_profiles: also fetch the list of available profiles.
|
|
|
|
Returns:
|
|
The GPU entry extended with profiles, active, and error.
|
|
"""
|
|
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]:
|
|
"""Collect status for all GPUs in one go.
|
|
|
|
Args:
|
|
with_profiles: include the available-profile lists.
|
|
force_gpus: bypass the GPU list cache.
|
|
|
|
Returns:
|
|
{"gpus": [per-GPU entries], "error": "" or an error string}.
|
|
"""
|
|
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:
|
|
"""Render the compact state fragment (polling view, active profiles only).
|
|
|
|
Args:
|
|
message: transient success message, or "".
|
|
error: error to display (overrides gather errors), or "".
|
|
|
|
Returns:
|
|
The rendered lact_state.html fragment.
|
|
"""
|
|
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:
|
|
"""Render the full skeleton fragment (initial + post-action view).
|
|
|
|
Always refreshes the GPU list and fetches every profile list, since
|
|
this is what the dropdowns are built from.
|
|
|
|
Args:
|
|
message: transient success message, or "".
|
|
error: error to display (overrides gather errors), or "".
|
|
|
|
Returns:
|
|
The rendered lact_skeleton.html fragment.
|
|
"""
|
|
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():
|
|
"""Poll endpoint: return the compact state fragment."""
|
|
return HTMLResponse(await _state())
|
|
|
|
|
|
@router.post("/set")
|
|
async def set_profile(gpu_id: Annotated[str, Form()], profile: Annotated[str, Form()]):
|
|
"""Set a power profile on a GPU, then re-render the skeleton.
|
|
|
|
Serialized by a module-level lock (lact does not tolerate concurrent
|
|
profile sets). The requested gpu_id and profile are validated against
|
|
a fresh, forced gather — unknown values are reported in the fragment.
|
|
Setting the already-active profile is a no-op with an explanatory
|
|
message.
|
|
|
|
Args:
|
|
gpu_id: GPU id from the form.
|
|
profile: profile name from the dropdown.
|
|
|
|
Returns:
|
|
The skeleton fragment with a success message or error.
|
|
"""
|
|
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: Annotated[str | None, Form()] = None):
|
|
"""Refresh the profile lists by re-rendering the skeleton.
|
|
|
|
The gpu_id form field is accepted but ignored: the skeleton gather
|
|
always forces a full re-fetch of all GPUs and profiles.
|
|
|
|
Args:
|
|
gpu_id: submitted GPU id (unused).
|
|
|
|
Returns:
|
|
The skeleton fragment with a refresh message.
|
|
"""
|
|
_ = 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,
|
|
)
|