Adds an experimental power-cap mode using the undocumented RM ioctl interface (based on panchovix's LACT PR #1205) to set power limits below the VBIOS minimum (down to 30 W). - hal/rm_power.py: RM ioctl power-cap read/write/reset + runtime probe - limits.py: power_cap_mode (nvml/ioctl) with support detection - config.py: persist power_cap_mode per GPU - profiles: record/apply power_cap_mode - server.py: POST /api/limits validates ioctl support (409 on failure) - cli.py: profile save falls back to persisted mode - client.py: power_cap_mode in Limits - frontend: toggle + warning with panchovix attribution (LACT #1205) - tests: test_rm_power.py (unit) + integration coverage - Makefile: add test_rm_power.py to make test Also includes automated linter reformatting (prettier, ruff, shellcheck, isort, markdownlint) that the linter would apply anyway.
359 lines
14 KiB
TypeScript
359 lines
14 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import { Check, X, RotateCcw } from "lucide-react";
|
|
import { api } from "../../api/client.js";
|
|
import { useCurveStore } from "../../store/curveStore.js";
|
|
import type { LimitsState } from "../../types.js";
|
|
import { toast } from "sonner";
|
|
import { ConfirmDialog } from "../common/ConfirmDialog.js";
|
|
|
|
type Pending = Pick<LimitsState, "power_limit_w" | "mem_offset_mhz">;
|
|
|
|
export function PerformancePanel() {
|
|
const { selectedGpuIndex } = useCurveStore();
|
|
const [limits, setLimits] = useState<LimitsState | null>(null);
|
|
const [pending, setPending] = useState<Partial<Pending>>({});
|
|
const [loading, setLoading] = useState(true);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [confirmApply, setConfirmApply] = useState(false);
|
|
const [confirmReset, setConfirmReset] = useState(false);
|
|
|
|
async function fetchLimits() {
|
|
try {
|
|
setLoading(true);
|
|
setLimits(await api.limits(selectedGpuIndex));
|
|
} catch {
|
|
toast.error("Failed to load performance limits");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
// Data fetch on GPU change — setState calls happen after the await, not
|
|
// synchronously in the effect body (rule false-positive on async fetch).
|
|
useEffect(() => {
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
fetchLimits();
|
|
}, [selectedGpuIndex]);
|
|
|
|
async function handleApply() {
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
await api.updateLimits(pending, selectedGpuIndex);
|
|
useCurveStore.getState().setActiveProfile(null);
|
|
setPending({});
|
|
setConfirmApply(false);
|
|
await fetchLimits();
|
|
toast.success("Performance limits applied");
|
|
} catch (e: unknown) {
|
|
setError(e instanceof Error ? e.message : String(e));
|
|
setConfirmApply(false);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function handleReset() {
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
await api.resetLimits(selectedGpuIndex);
|
|
useCurveStore.getState().setActiveProfile(null);
|
|
setPending({});
|
|
setConfirmReset(false);
|
|
await fetchLimits();
|
|
toast.success("Performance limits reset to defaults");
|
|
} catch (e: unknown) {
|
|
setError(e instanceof Error ? e.message : String(e));
|
|
setConfirmReset(false);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function handleModeChange(enabled: boolean) {
|
|
setBusy(true);
|
|
try {
|
|
await api.updateLimits(
|
|
{ power_cap_mode: enabled ? "ioctl" : "nvml" },
|
|
selectedGpuIndex,
|
|
);
|
|
await fetchLimits();
|
|
} catch (e: unknown) {
|
|
toast.error(e instanceof Error ? e.message : String(e));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
if (loading && !limits) {
|
|
return (
|
|
<div className="bg-zinc-900 rounded-lg overflow-hidden flex flex-col animate-pulse">
|
|
<div className="px-3 py-2 border-b border-zinc-800 h-9" />
|
|
<div className="p-4 flex flex-col gap-6">
|
|
<div className="h-12 bg-zinc-800 rounded" />
|
|
<div className="h-12 bg-zinc-800 rounded" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!limits) return null;
|
|
|
|
const hasPending = Object.keys(pending).length > 0;
|
|
|
|
// Power
|
|
const pwrVal = pending.power_limit_w ?? limits.power_limit_w ?? 0;
|
|
const pwrMin = limits.min_power_limit_w ?? 100;
|
|
const pwrMax = limits.max_power_limit_w ?? 600;
|
|
|
|
// Memory offset
|
|
const memVal = pending.mem_offset_mhz ?? limits.mem_offset_mhz ?? 0;
|
|
const memMin = limits.min_mem_offset_mhz ?? -2000;
|
|
const memMax = Math.min(limits.max_mem_offset_mhz ?? 3000, 3000);
|
|
|
|
return (
|
|
<>
|
|
<div className="bg-zinc-900 rounded-lg overflow-hidden flex flex-col">
|
|
{/* ── Header ─────────────────────────────────────────────────────── */}
|
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-zinc-800 shrink-0">
|
|
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">
|
|
Performance
|
|
</span>
|
|
|
|
{hasPending && (
|
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-cyan-500/15 border border-cyan-500/30 text-cyan-400 text-xs">
|
|
{Object.keys(pending).length} pending
|
|
</span>
|
|
)}
|
|
|
|
<div className="ml-auto flex items-center gap-2">
|
|
<button
|
|
onClick={() => setConfirmApply(true)}
|
|
disabled={!hasPending || busy}
|
|
className="flex items-center gap-1.5 px-2 py-1 rounded bg-emerald-700 hover:bg-emerald-600 text-white text-xs font-semibold transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
|
>
|
|
<Check size={12} />
|
|
Apply
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setPending({});
|
|
setError(null);
|
|
}}
|
|
disabled={!hasPending || busy}
|
|
className="flex items-center gap-1.5 px-2 py-1 rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
|
>
|
|
<X size={12} />
|
|
Discard
|
|
</button>
|
|
<button
|
|
onClick={() => setConfirmReset(true)}
|
|
disabled={busy}
|
|
className="flex items-center gap-1.5 px-2 py-1 rounded bg-zinc-800 hover:bg-red-900 text-zinc-500 hover:text-red-300 text-xs transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
|
>
|
|
<RotateCcw size={11} />
|
|
Reset
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Error banner ────────────────────────────────────────────────── */}
|
|
{error && (
|
|
<div className="px-3 py-1.5 bg-red-900/40 border-b border-red-700 text-red-300 text-xs flex items-center justify-between">
|
|
<span>⚠ {error}</span>
|
|
<button
|
|
onClick={() => setError(null)}
|
|
className="ml-2 text-red-400 hover:text-red-200"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-col divide-y divide-zinc-800">
|
|
{/* ── Experimental NVIDIA power control ─────────────────────────── */}
|
|
{(limits.rm_power_supported || limits.power_cap_mode === "ioctl") && (
|
|
<div className="px-4 py-3 flex flex-col gap-2">
|
|
<label className="flex items-center gap-2 cursor-pointer select-none">
|
|
<input
|
|
type="checkbox"
|
|
checked={limits.power_cap_mode === "ioctl"}
|
|
disabled={busy}
|
|
onChange={(e) => handleModeChange(e.target.checked)}
|
|
className="accent-red-500"
|
|
/>
|
|
<span className="text-xs text-zinc-300">
|
|
Experimental NVIDIA power control
|
|
</span>
|
|
</label>
|
|
{limits.rm_power_supported ? (
|
|
<div
|
|
role="alert"
|
|
className={
|
|
"px-2.5 py-1.5 rounded border text-xs leading-relaxed " +
|
|
(limits.power_cap_mode === "ioctl"
|
|
? "bg-red-950/80 border-red-500 text-red-300"
|
|
: "bg-red-950/40 border-red-800 text-red-400")
|
|
}
|
|
>
|
|
<span className="font-bold">⚠ WARNING:</span> uses an
|
|
undocumented driver interface for ALL power limits, including
|
|
resets. Allows values below the VBIOS minimum (down to 30 W)
|
|
and may cause instability or stop working after driver
|
|
updates. Enable at your own risk. Based on the work of{" "}
|
|
<a
|
|
href="https://github.com/ilya-zlobintsev/LACT/pull/1205"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="underline hover:text-red-200"
|
|
>
|
|
panchovix
|
|
</a>{" "}
|
|
(LACT PR #1205).
|
|
</div>
|
|
) : (
|
|
<div
|
|
role="alert"
|
|
className="px-2.5 py-1.5 rounded border border-red-500 bg-red-950/80 text-red-300 text-xs leading-relaxed"
|
|
>
|
|
<span className="font-bold">
|
|
⚠ Interface not currently available.
|
|
</span>
|
|
The driver no longer exposes the RM power interface (it may
|
|
have been updated). Experimental mode is still enabled, so
|
|
power-limit changes will fail. Uncheck to switch back to the
|
|
standard NVML mode.
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Board Power Limit ─────────────────────────────────────────── */}
|
|
<div className="px-4 py-4 flex flex-col gap-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-zinc-500 uppercase tracking-wider">
|
|
Board Power Limit
|
|
</span>
|
|
{limits.power_cap_mode === "ioctl" &&
|
|
limits.min_power_limit_w_native != null && (
|
|
<span
|
|
className="text-xs text-red-400/80 font-mono"
|
|
title="Native VBIOS minimum — experimental mode allows lower"
|
|
>
|
|
VBIOS min {limits.min_power_limit_w_native} W
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<input
|
|
type="number"
|
|
min={pwrMin}
|
|
max={pwrMax}
|
|
value={pwrVal}
|
|
onChange={(e) => {
|
|
const v = parseInt(e.target.value);
|
|
if (!isNaN(v))
|
|
setPending((p) => ({ ...p, power_limit_w: v }));
|
|
}}
|
|
className="w-14 bg-zinc-950 border border-zinc-800 rounded text-xs px-2 py-1 text-right font-mono focus:outline-none focus:border-cyan-500"
|
|
/>
|
|
<span className="text-xs text-zinc-600 w-3">W</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-zinc-600 font-mono w-8 text-right">
|
|
{pwrMin}
|
|
</span>
|
|
<input
|
|
type="range"
|
|
min={pwrMin}
|
|
max={pwrMax}
|
|
value={pwrVal}
|
|
onChange={(e) =>
|
|
setPending((p) => ({
|
|
...p,
|
|
power_limit_w: parseInt(e.target.value),
|
|
}))
|
|
}
|
|
className="flex-1 accent-cyan-400 h-1 cursor-pointer"
|
|
/>
|
|
<span className="text-xs text-zinc-600 font-mono w-8">
|
|
{pwrMax}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Memory Clock Offset ───────────────────────────────────────── */}
|
|
<div className="px-4 py-4 flex flex-col gap-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs text-zinc-500 uppercase tracking-wider">
|
|
Memory Clock Offset
|
|
</span>
|
|
<div className="flex items-center gap-1.5">
|
|
<input
|
|
type="number"
|
|
min={memMin}
|
|
max={memMax}
|
|
value={memVal}
|
|
onChange={(e) => {
|
|
const v = parseInt(e.target.value);
|
|
if (!isNaN(v))
|
|
setPending((p) => ({ ...p, mem_offset_mhz: v }));
|
|
}}
|
|
className="w-16 bg-zinc-950 border border-zinc-800 rounded text-xs px-2 py-1 text-right font-mono focus:outline-none focus:border-cyan-500"
|
|
/>
|
|
<span className="text-xs text-zinc-600 w-6">MHz</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-zinc-600 font-mono w-10 text-right">
|
|
{memMin}
|
|
</span>
|
|
<input
|
|
type="range"
|
|
min={memMin}
|
|
max={memMax}
|
|
step={1}
|
|
value={memVal}
|
|
onChange={(e) =>
|
|
setPending((p) => ({
|
|
...p,
|
|
mem_offset_mhz: parseInt(e.target.value),
|
|
}))
|
|
}
|
|
className="flex-1 accent-cyan-400 h-1 cursor-pointer"
|
|
/>
|
|
<span className="text-xs text-zinc-600 font-mono w-10">
|
|
+{memMax}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{confirmApply && (
|
|
<ConfirmDialog
|
|
message="Apply performance limits to hardware?"
|
|
detail="Power limit and memory clock offset take effect immediately."
|
|
confirmLabel="Apply"
|
|
onConfirm={handleApply}
|
|
onCancel={() => setConfirmApply(false)}
|
|
/>
|
|
)}
|
|
{confirmReset && (
|
|
<ConfirmDialog
|
|
message="Reset performance limits to defaults?"
|
|
detail="Power limit will be restored to the hardware default and memory clock offset set to 0."
|
|
confirmLabel="Reset"
|
|
isDestructive
|
|
onConfirm={handleReset}
|
|
onCancel={() => setConfirmReset(false)}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|