Compare commits
5
Commits
8956fc9d7b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc102f26c1 | ||
|
|
e148c83622 | ||
|
|
a8462e696c | ||
|
|
d810c44478 | ||
|
|
39701c12ff |
No files matched your search
@@ -0,0 +1,30 @@
|
|||||||
|
# NVCurve — developer convenience targets.
|
||||||
|
#
|
||||||
|
# End users don't need make: run ./install.sh (see README "Installation").
|
||||||
|
|
||||||
|
UV ?= uv
|
||||||
|
NPM ?= npm
|
||||||
|
|
||||||
|
.PHONY: help frontend frontend-dev install dev test clean
|
||||||
|
|
||||||
|
help: ## Show available targets
|
||||||
|
@grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}'
|
||||||
|
|
||||||
|
frontend: ## Build the React frontend into frontend/dist
|
||||||
|
cd frontend && $(NPM) ci && $(NPM) run build
|
||||||
|
|
||||||
|
frontend-dev: ## Run the Vite dev server (hot reload)
|
||||||
|
cd frontend && $(NPM) run dev
|
||||||
|
|
||||||
|
install: ## Install nvcurve as a uv tool (builds frontend if missing/stale)
|
||||||
|
$(UV) tool install --force .
|
||||||
|
|
||||||
|
dev: ## Create/refresh the dev environment (uv sync)
|
||||||
|
$(UV) sync
|
||||||
|
|
||||||
|
test: ## Run the test suite
|
||||||
|
$(UV) run python tests/test_security.py
|
||||||
|
$(UV) run python tests/test_rm_power.py
|
||||||
|
|
||||||
|
clean: ## Remove build artifacts
|
||||||
|
rm -rf frontend/dist frontend/node_modules
|
||||||
@@ -37,20 +37,28 @@ NVCurve brings MSI Afterburner-style per-point voltage-frequency curve control t
|
|||||||
- **[uv](https://docs.astral.sh/uv/)** — Python package manager
|
- **[uv](https://docs.astral.sh/uv/)** — Python package manager
|
||||||
- **Root/sudo access** (required for GPU hardware interactions)
|
- **Root/sudo access** (required for GPU hardware interactions)
|
||||||
|
|
||||||
## Installation from Source
|
## Installation
|
||||||
|
|
||||||
|
### One-liner
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone <this-repo-url>.git
|
curl -fsSL https://gitea.zephyre.one/Pakobbix/nvcurve/raw/branch/main/install.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
The script checks prerequisites (installs `uv` if missing), clones the repo, and installs NVCurve — the React frontend is compiled automatically during the build.
|
||||||
|
|
||||||
|
### From a clone
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://gitea.zephyre.one/Pakobbix/nvcurve.git
|
||||||
cd nvcurve
|
cd nvcurve
|
||||||
|
./install.sh
|
||||||
|
```
|
||||||
|
|
||||||
# Build the React frontend
|
### Direct from git (no clone, no script)
|
||||||
cd frontend
|
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
cd ..
|
|
||||||
|
|
||||||
# Install the Python package (includes bundled frontend)
|
```bash
|
||||||
uv tool install .
|
uv tool install "git+https://gitea.zephyre.one/Pakobbix/nvcurve.git"
|
||||||
```
|
```
|
||||||
|
|
||||||
After installation, verify hardware compatibility:
|
After installation, verify hardware compatibility:
|
||||||
@@ -78,6 +86,20 @@ sudo nvcurve user remove alice # remove a user
|
|||||||
|
|
||||||
Adding the first user enables authentication immediately; removing the last user disables it. See the [Usage Guide](docs/Usage-Guide.md#authentication-multi-user) for details.
|
Adding the first user enables authentication immediately; removing the last user disables it. See the [Usage Guide](docs/Usage-Guide.md#authentication-multi-user) for details.
|
||||||
|
|
||||||
|
## TLS (HTTPS)
|
||||||
|
|
||||||
|
The server speaks **plain HTTP by default**. For network access (e.g. behind a reverse proxy or on a LAN), you can enable TLS so the web UI, API, and WebSocket all run over HTTPS — the session cookie is then marked `Secure`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# One-off (this server run only)
|
||||||
|
nvcurve serve start --ssl-certfile /path/to/cert.pem --ssl-keyfile /path/to/key.pem
|
||||||
|
|
||||||
|
# Persistent (stored in /etc/nvcurve/config.json; used by the daemon too)
|
||||||
|
sudo nvcurve service configure --ssl-certfile /path/to/cert.pem --ssl-keyfile /path/to/key.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
With TLS enabled the UI is at `https://<host>:8042` and the CLI switches to `https://` automatically. A self-signed certificate works for local use (the browser will warn); for multi-user setups use a certificate your browser trusts (e.g. via your internal CA or a reverse proxy).
|
||||||
|
|
||||||
## Systemd Service
|
## Systemd Service
|
||||||
|
|
||||||
Install the daemon for automatic profile loading on boot and optional web server auto-start:
|
Install the daemon for automatic profile loading on boot and optional web server auto-start:
|
||||||
@@ -149,6 +171,10 @@ The daemon reads settings from `/etc/nvcurve/config.json`:
|
|||||||
"max_delta_khz": 3000000,
|
"max_delta_khz": 3000000,
|
||||||
"auto_snapshot": true,
|
"auto_snapshot": true,
|
||||||
"max_snapshots": 20,
|
"max_snapshots": 20,
|
||||||
|
"ssl_certfile": null,
|
||||||
|
"ssl_keyfile": null,
|
||||||
|
"trusted_proxies": [],
|
||||||
|
"allow_api_shutdown": true,
|
||||||
"auto_load_profiles": {
|
"auto_load_profiles": {
|
||||||
"idx:0": "my_profile"
|
"idx:0": "my_profile"
|
||||||
}
|
}
|
||||||
@@ -160,9 +186,12 @@ The daemon reads settings from `/etc/nvcurve/config.json`:
|
|||||||
| `host` | Web server bind address (`0.0.0.0` for network access) |
|
| `host` | Web server bind address (`0.0.0.0` for network access) |
|
||||||
| `port` | Web server port (default `8042`) |
|
| `port` | Web server port (default `8042`) |
|
||||||
| `auto_serve` | Auto-start web server on boot |
|
| `auto_serve` | Auto-start web server on boot |
|
||||||
| `max_delta_khz` | Safety cap for frequency offsets (default 3000 MHz) |
|
| `max_delta_khz` | Safety cap for frequency offsets (default 3000 MHz). Enforced server-side; API clients cannot raise it per request |
|
||||||
| `auto_snapshot` | Save snapshot before every write |
|
| `auto_snapshot` | Save snapshot before every write |
|
||||||
| `max_snapshots` | Max snapshots to keep (`0` = unlimited) |
|
| `max_snapshots` | Max snapshots to keep (`0` = unlimited) |
|
||||||
|
| `ssl_certfile` / `ssl_keyfile` | TLS certificate/key — enables HTTPS when both are set (default: off) |
|
||||||
|
| `trusted_proxies` | Proxy IPs whose `X-Forwarded-For` is trusted for the login lockout (e.g. `["127.0.0.1"]` for a local reverse proxy) |
|
||||||
|
| `allow_api_shutdown` | Allow authenticated users to stop the server via `POST /api/shutdown` (set `false` on shared systems; use systemd instead) |
|
||||||
| `auto_load_profiles` | Per-GPU profile to apply on boot (`{gpu_key: profile_name}`) |
|
| `auto_load_profiles` | Per-GPU profile to apply on boot (`{gpu_key: profile_name}`) |
|
||||||
|
|
||||||
The GPU key can be a UUID, `pci:XXXX`, or `idx:N` fallback. Find your GPU key with `nvcurve gpus`.
|
The GPU key can be a UUID, `pci:XXXX`, or `idx:N` fallback. Find your GPU key with `nvcurve gpus`.
|
||||||
@@ -179,10 +208,11 @@ The GPU key can be a UUID, `pci:XXXX`, or `idx:N` fallback. Find your GPU key wi
|
|||||||
```bash
|
```bash
|
||||||
cd nvcurve
|
cd nvcurve
|
||||||
git pull
|
git pull
|
||||||
cd frontend && npm run build && cd ..
|
uv tool install --force .
|
||||||
uv tool install .
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The frontend is rebuilt automatically if it is missing or older than the frontend sources. If you modified frontend code locally, run `make frontend` first (or `rm -rf frontend/dist`).
|
||||||
|
|
||||||
If running as a systemd service:
|
If running as a systemd service:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+29
-19
@@ -29,43 +29,52 @@ sudo pacman -S uv
|
|||||||
pip install uv
|
pip install uv
|
||||||
```
|
```
|
||||||
|
|
||||||
## Installation from Source
|
## Installation
|
||||||
|
|
||||||
### Step 1: Clone the Repository
|
### Option 1: One-liner (recommended)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone <this-repo-url>.git
|
curl -fsSL https://gitea.zephyre.one/Pakobbix/nvcurve/raw/branch/main/install.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
The script checks prerequisites (installs `uv` if missing), clones the repository, and installs NVCurve. The React frontend is compiled automatically during the build by a hatchling build hook (`hatch_build.py`).
|
||||||
|
|
||||||
|
### Option 2: From a clone
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://gitea.zephyre.one/Pakobbix/nvcurve.git
|
||||||
cd nvcurve
|
cd nvcurve
|
||||||
|
./install.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 2: Build the Frontend
|
Equivalent manual steps (what the script does):
|
||||||
|
|
||||||
The frontend is a React + TypeScript + Vite application in the `frontend/` directory.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
git clone https://gitea.zephyre.one/Pakobbix/nvcurve.git
|
||||||
npm install
|
cd nvcurve
|
||||||
npm run build
|
uv tool install . # frontend is built automatically if missing/stale
|
||||||
cd ..
|
|
||||||
```
|
```
|
||||||
|
|
||||||
This produces a `dist/` directory with the compiled static assets. The hatch build system bundles `frontend/dist` into the Python package.
|
### Option 3: Direct from git (no clone, no script)
|
||||||
|
|
||||||
### Step 3: Install the Python Package
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv tool install .
|
uv tool install "git+https://gitea.zephyre.one/Pakobbix/nvcurve.git"
|
||||||
```
|
```
|
||||||
|
|
||||||
This installs `nvcurve` as a system-wide tool with the bundled frontend.
|
To install a specific branch:
|
||||||
|
|
||||||
### Step 4: Verify
|
```bash
|
||||||
|
uv tool install "git+https://gitea.zephyre.one/Pakobbix/nvcurve.git@<branch>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verify
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nvcurve setup
|
nvcurve setup
|
||||||
```
|
```
|
||||||
|
|
||||||
This performs four checks:
|
This performs four checks:
|
||||||
|
|
||||||
1. **NvAPI function probe** — verifies all required functions resolve in your driver
|
1. **NvAPI function probe** — verifies all required functions resolve in your driver
|
||||||
2. **Curve read** — reads and displays your current V/F curve as a baseline
|
2. **Curve read** — reads and displays your current V/F curve as a baseline
|
||||||
3. **Write-verify** — writes `+5 MHz` to a safe point, reads it back, and confirms the change
|
3. **Write-verify** — writes `+5 MHz` to a safe point, reads it back, and confirms the change
|
||||||
@@ -94,10 +103,11 @@ nvcurve serve start
|
|||||||
```bash
|
```bash
|
||||||
cd nvcurve
|
cd nvcurve
|
||||||
git pull
|
git pull
|
||||||
cd frontend && npm run build && cd ..
|
uv tool install --force .
|
||||||
uv tool install .
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The frontend is rebuilt automatically if it is missing or older than the frontend sources. If you modified frontend code locally, run `make frontend` first (or `rm -rf frontend/dist`).
|
||||||
|
|
||||||
If running as a systemd service:
|
If running as a systemd service:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -116,7 +126,7 @@ source ~/.local/bin/env # or wherever uv installed
|
|||||||
|
|
||||||
### Frontend not loading in the web UI
|
### Frontend not loading in the web UI
|
||||||
|
|
||||||
Verify that `frontend/dist` exists and contains built assets. If the directory is empty or missing, rebuild with `npm run build` and reinstall with `uv tool install .`.
|
Verify that the installed package contains the frontend. If `frontend/dist` is empty or missing, rebuild with `make frontend` (or `cd frontend && npm ci && npm run build`) and reinstall with `uv tool install --force .`.
|
||||||
|
|
||||||
### NvAPI functions not found
|
### NvAPI functions not found
|
||||||
|
|
||||||
|
|||||||
+40
-1
@@ -84,6 +84,23 @@ The monitoring panel shows real-time GPU metrics:
|
|||||||
|
|
||||||
Data is streamed via WebSocket from the backend at a configurable poll interval (default: 1 second).
|
Data is streamed via WebSocket from the backend at a configurable poll interval (default: 1 second).
|
||||||
|
|
||||||
|
### Performance Limits
|
||||||
|
|
||||||
|
The Performance panel controls the board power limit and the memory clock offset. The power limit slider is bounded by the GPU's VBIOS minimum and maximum (shown at the slider ends); changes are applied on **Apply** and reset to the hardware default on **Reset**.
|
||||||
|
|
||||||
|
#### Experimental NVIDIA power control
|
||||||
|
|
||||||
|
On compatible drivers, an **Experimental NVIDIA power control** checkbox appears in the Performance panel. Enabling it switches power-limit application from the standard NVML call to an undocumented driver (RM) interface, which **permits caps below the VBIOS minimum, down to 30 W**. The native maximum still applies.
|
||||||
|
|
||||||
|
> **Warning.** This uses an undocumented driver interface for *all* power limits, including resets. It may cause instability or stop working after a driver update. Enable it at your own risk. The option is clearly labelled with a red warning in the UI, and profiles saved while it is enabled are marked accordingly.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- The checkbox only appears when the driver exposes a compatible RM power layout (detected with a read-only probe — no writes).
|
||||||
|
- In this mode there is **no automatic fallback** to NVML: if the RM route fails, the error is reported rather than silently switching backends.
|
||||||
|
- The mode is per-GPU and persisted across server restarts. Reset restores the default through the same route, so it can also clear a previously-set below-minimum cap.
|
||||||
|
- The CLI reports availability via `nvcurve read --diag` ("Experimental RM power: available").
|
||||||
|
|
||||||
### Multi-GPU
|
### Multi-GPU
|
||||||
|
|
||||||
When multiple NVIDIA GPUs are detected, a GPU selector dropdown appears in the status bar. Switching GPUs resets pending edits, selection state, and monitoring for the new target.
|
When multiple NVIDIA GPUs are detected, a GPU selector dropdown appears in the status bar. Switching GPUs resets pending edits, selection state, and monitoring for the new target.
|
||||||
@@ -145,6 +162,27 @@ Adding the first user **switches the server into authenticated mode immediately*
|
|||||||
- The user store file should stay root-owned and `0600` (the CLI enforces this).
|
- The user store file should stay root-owned and `0600` (the CLI enforces this).
|
||||||
- The web UI and API are still only as safe as the network path to the server — bind to a trusted interface (`--host`) and/or firewall the port. Authentication protects against casual access, not a determined network attacker.
|
- The web UI and API are still only as safe as the network path to the server — bind to a trusted interface (`--host`) and/or firewall the port. Authentication protects against casual access, not a determined network attacker.
|
||||||
- The `nvcurve user` commands and the user store require root; day-to-day sign-in does not.
|
- The `nvcurve user` commands and the user store require root; day-to-day sign-in does not.
|
||||||
|
- The login lockout is keyed by client IP. Behind a reverse proxy all clients share the proxy's IP — set `trusted_proxies` in `/etc/nvcurve/config.json` (e.g. `["127.0.0.1"]`) so the lockout uses the real client IP from `X-Forwarded-For`. The header is only honoured for peers you list there (it is spoofable otherwise).
|
||||||
|
- On shared systems consider setting `allow_api_shutdown: false` so users cannot stop the server via the API (manage it with systemd instead).
|
||||||
|
- The frequency safety cap (`max_delta_khz`) is enforced by the server from its config; API clients cannot raise it per request. The CLI's `--max-delta` (root-only, direct hardware path) can still override it for a single write.
|
||||||
|
|
||||||
|
## TLS (HTTPS)
|
||||||
|
|
||||||
|
The server speaks **plain HTTP by default**. When you expose it beyond localhost, enable TLS so credentials and session cookies are not sent in cleartext:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Persistent (stored in /etc/nvcurve/config.json, used by the daemon too)
|
||||||
|
sudo nvcurve service configure --ssl-certfile /path/to/cert.pem --ssl-keyfile /path/to/key.pem
|
||||||
|
|
||||||
|
# One-off
|
||||||
|
nvcurve serve start --ssl-certfile /path/to/cert.pem --ssl-keyfile /path/to/key.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
- Both files must be set for TLS to activate; the UI then lives at `https://<host>:8042` and the WebSocket upgrades to `wss://` automatically.
|
||||||
|
- To disable TLS again: `sudo nvcurve service configure --no-ssl` (removes the certificate/key from the config).
|
||||||
|
- The session cookie gets the `Secure` flag, so it is only sent over HTTPS.
|
||||||
|
- A self-signed certificate is fine for a home LAN (the browser shows a warning); for multi-user setups use a certificate your browser trusts.
|
||||||
|
- The CLI detects TLS from the config/runtime info and switches to `https://` automatically.
|
||||||
|
|
||||||
## CLI Reference
|
## CLI Reference
|
||||||
|
|
||||||
@@ -251,13 +289,14 @@ nvcurve service uninstall
|
|||||||
sudo nvcurve service configure --auto-serve
|
sudo nvcurve service configure --auto-serve
|
||||||
sudo nvcurve service configure --no-auto-serve
|
sudo nvcurve service configure --no-auto-serve
|
||||||
sudo nvcurve service configure --host 0.0.0.0 --port 8042
|
sudo nvcurve service configure --host 0.0.0.0 --port 8042
|
||||||
|
sudo nvcurve service configure --ssl-certfile /path/to/cert.pem --ssl-keyfile /path/to/key.pem
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration Files
|
## Configuration Files
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `/etc/nvcurve/config.json` | Persistent config (host, port, auto-serve, default profiles) |
|
| `/etc/nvcurve/config.json` | Persistent config (host, port, auto-serve, TLS, safety cap, default profiles) |
|
||||||
| `/etc/nvcurve/profiles/*.json` | Saved profiles |
|
| `/etc/nvcurve/profiles/*.json` | Saved profiles |
|
||||||
| `/var/cache/nvcurve/snapshots/` | Auto-saved snapshots before writes |
|
| `/var/cache/nvcurve/snapshots/` | Auto-saved snapshots before writes |
|
||||||
| `/run/nvcurve.json` | Runtime server info (host, port, PID) |
|
| `/run/nvcurve.json` | Runtime server info (host, port, PID) |
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { fmt } from '../../utils/units.js';
|
import { fmt } from "../../utils/units.js";
|
||||||
import type { VFPoint } from '../../types.js';
|
import type { VFPoint } from "../../types.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
point: VFPoint;
|
point: VFPoint;
|
||||||
/** Pending delta in kHz (from store), if any */
|
/** Pending delta in kHz (from store), if any */
|
||||||
pendingDeltaKhz?: number;
|
pendingDeltaKhz?: number;
|
||||||
/** True if this point is being held up by monotonicity enforcement */
|
/** True if this point is being held up by monotonicity enforcement */
|
||||||
isClamped?: boolean;
|
isClamped?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,49 +14,70 @@ interface Props {
|
|||||||
* SVG so it's never clipped by the SVG viewport.
|
* SVG so it's never clipped by the SVG viewport.
|
||||||
*/
|
*/
|
||||||
export function CurveTooltip({ point, pendingDeltaKhz, isClamped }: Props) {
|
export function CurveTooltip({ point, pendingDeltaKhz, isClamped }: Props) {
|
||||||
const hasPending = pendingDeltaKhz !== undefined;
|
const hasPending = pendingDeltaKhz !== undefined;
|
||||||
const pendingMhz = hasPending ? pendingDeltaKhz! / 1000 : 0;
|
const pendingMhz = hasPending ? pendingDeltaKhz! / 1000 : 0;
|
||||||
const deltaChange = hasPending ? pendingDeltaKhz! - point.delta_khz : 0;
|
const deltaChange = hasPending ? pendingDeltaKhz! - point.delta_khz : 0;
|
||||||
const pendingEffMhz = hasPending ? point.freq_mhz + deltaChange / 1000 : null;
|
const pendingEffMhz = hasPending
|
||||||
|
? point.freq_mhz + deltaChange / 1000
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="absolute right-4 bottom-4 pointer-events-none z-50 w-[172px] bg-zinc-800 border border-zinc-700 rounded-md p-2 text-xs shadow-xl">
|
||||||
className="absolute right-4 bottom-4 pointer-events-none z-50 w-[172px] bg-zinc-800 border border-zinc-700 rounded-md p-2 text-xs shadow-xl"
|
<div className="text-zinc-400 mb-1">Point {point.index}</div>
|
||||||
>
|
|
||||||
<div className="text-zinc-400 mb-1">Point {point.index}</div>
|
|
||||||
<div className="text-zinc-200">
|
|
||||||
<span className="text-zinc-400">Volt: </span>{fmt.mv(point.volt_mv, 1)}
|
|
||||||
</div>
|
|
||||||
<div className="text-zinc-200">
|
|
||||||
<span className="text-zinc-400">Offset: </span>
|
|
||||||
<span className={point.delta_khz > 0 ? 'text-emerald-400' : point.delta_khz < 0 ? 'text-red-400' : 'text-zinc-400'}>
|
|
||||||
{point.delta_khz > 0 ? '+' : ''}{fmt.mhz(point.delta_mhz, 1)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-emerald-300 font-semibold">
|
|
||||||
<span className="text-zinc-400">Eff.: </span>{fmt.mhz(point.freq_mhz, 0)}
|
|
||||||
{isClamped && <span className="text-amber-500 ml-1">⇡</span>}
|
|
||||||
</div>
|
|
||||||
{isClamped && (
|
|
||||||
<div className="text-amber-500/80 text-[10px] mt-0.5">
|
|
||||||
Clamped by lower-voltage point
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{hasPending && (
|
|
||||||
<>
|
|
||||||
<div className="border-t border-zinc-700 mt-1.5 pt-1.5">
|
|
||||||
<div className="text-zinc-200">
|
<div className="text-zinc-200">
|
||||||
<span className="text-zinc-400">Pending: </span>
|
<span className="text-zinc-400">Volt: </span>
|
||||||
<span className={pendingMhz > 0 ? 'text-cyan-400' : pendingMhz < 0 ? 'text-orange-400' : 'text-zinc-400'}>
|
{fmt.mv(point.volt_mv, 1)}
|
||||||
{pendingMhz > 0 ? '+' : ''}{pendingMhz.toFixed(1)} MHz
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-cyan-300 font-semibold">
|
<div className="text-zinc-200">
|
||||||
<span className="text-zinc-400">→ Eff.: </span>{fmt.mhz(pendingEffMhz, 0)}
|
<span className="text-zinc-400">Offset: </span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
point.delta_khz > 0
|
||||||
|
? "text-emerald-400"
|
||||||
|
: point.delta_khz < 0
|
||||||
|
? "text-red-400"
|
||||||
|
: "text-zinc-400"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{point.delta_khz > 0 ? "+" : ""}
|
||||||
|
{fmt.mhz(point.delta_mhz, 1)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="text-emerald-300 font-semibold">
|
||||||
</>
|
<span className="text-zinc-400">Eff.: </span>
|
||||||
)}
|
{fmt.mhz(point.freq_mhz, 0)}
|
||||||
</div>
|
{isClamped && <span className="text-amber-500 ml-1">⇡</span>}
|
||||||
);
|
</div>
|
||||||
|
{isClamped && (
|
||||||
|
<div className="text-amber-500/80 text-[10px] mt-0.5">
|
||||||
|
Clamped by lower-voltage point
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasPending && (
|
||||||
|
<>
|
||||||
|
<div className="border-t border-zinc-700 mt-1.5 pt-1.5">
|
||||||
|
<div className="text-zinc-200">
|
||||||
|
<span className="text-zinc-400">Pending: </span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
pendingMhz > 0
|
||||||
|
? "text-cyan-400"
|
||||||
|
: pendingMhz < 0
|
||||||
|
? "text-orange-400"
|
||||||
|
: "text-zinc-400"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{pendingMhz > 0 ? "+" : ""}
|
||||||
|
{pendingMhz.toFixed(1)} MHz
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-cyan-300 font-semibold">
|
||||||
|
<span className="text-zinc-400">→ Eff.: </span>
|
||||||
|
{fmt.mhz(pendingEffMhz, 0)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -72,6 +72,21 @@ export function PerformancePanel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
if (loading && !limits) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-zinc-900 rounded-lg overflow-hidden flex flex-col animate-pulse">
|
<div className="bg-zinc-900 rounded-lg overflow-hidden flex flex-col animate-pulse">
|
||||||
@@ -158,12 +173,80 @@ export function PerformancePanel() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col divide-y divide-zinc-800">
|
<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 ─────────────────────────────────────────── */}
|
{/* ── Board Power Limit ─────────────────────────────────────────── */}
|
||||||
<div className="px-4 py-4 flex flex-col gap-3">
|
<div className="px-4 py-4 flex flex-col gap-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs text-zinc-500 uppercase tracking-wider">
|
<div className="flex items-center gap-2">
|
||||||
Board Power Limit
|
<span className="text-xs text-zinc-500 uppercase tracking-wider">
|
||||||
</span>
|
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">
|
<div className="flex items-center gap-1.5">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
|||||||
@@ -345,6 +345,11 @@ export function ProfilePanel({
|
|||||||
{badges && (
|
{badges && (
|
||||||
<p className="text-xs text-zinc-500">{badges}</p>
|
<p className="text-xs text-zinc-500">{badges}</p>
|
||||||
)}
|
)}
|
||||||
|
{p.power_cap_mode === "ioctl" && (
|
||||||
|
<p className="text-xs text-red-400 font-medium">
|
||||||
|
⚠ experimental power (below VBIOS min)
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -98,7 +98,11 @@ export interface LimitsState {
|
|||||||
power_limit_w: number | null;
|
power_limit_w: number | null;
|
||||||
default_power_limit_w: number | null;
|
default_power_limit_w: number | null;
|
||||||
min_power_limit_w: number | null;
|
min_power_limit_w: number | null;
|
||||||
|
min_power_limit_w_native: number | null;
|
||||||
max_power_limit_w: number | null;
|
max_power_limit_w: number | null;
|
||||||
|
// "nvml" (default) or "ioctl" (experimental RM power control)
|
||||||
|
power_cap_mode: "nvml" | "ioctl";
|
||||||
|
rm_power_supported: boolean;
|
||||||
// Clock offsets — current values
|
// Clock offsets — current values
|
||||||
gpc_offset_mhz: number | null;
|
gpc_offset_mhz: number | null;
|
||||||
mem_offset_mhz: number | null;
|
mem_offset_mhz: number | null;
|
||||||
@@ -136,6 +140,8 @@ export interface ProfileData {
|
|||||||
curve_deltas: Record<string, number>;
|
curve_deltas: Record<string, number>;
|
||||||
mem_offset_mhz: number | null;
|
mem_offset_mhz: number | null;
|
||||||
power_limit_w: number | null;
|
power_limit_w: number | null;
|
||||||
|
// "nvml" (default) or "ioctl" (experimental RM power control)
|
||||||
|
power_cap_mode: "nvml" | "ioctl" | null;
|
||||||
fan_curve: FanPoint[] | null;
|
fan_curve: FanPoint[] | null;
|
||||||
fan_targets: number[] | null;
|
fan_targets: number[] | null;
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { VFPoint } from '../types.js';
|
import type { VFPoint } from "../types.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Approximate reference frequency (MHz) for a point: effective − delta.
|
* Approximate reference frequency (MHz) for a point: effective − delta.
|
||||||
@@ -14,35 +14,37 @@ import type { VFPoint } from '../types.js';
|
|||||||
* useful as a faint reference line ("where this point would sit with no boost").
|
* useful as a faint reference line ("where this point would sit with no boost").
|
||||||
*/
|
*/
|
||||||
export function refBaseMhz(p: VFPoint): number {
|
export function refBaseMhz(p: VFPoint): number {
|
||||||
return p.freq_mhz - p.delta_mhz;
|
return p.freq_mhz - p.delta_mhz;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Find which VF point the GPU is currently near based on voltage reading */
|
/** Find which VF point the GPU is currently near based on voltage reading */
|
||||||
export function findCurrentPoint(
|
export function findCurrentPoint(
|
||||||
points: VFPoint[],
|
points: VFPoint[],
|
||||||
voltage_mv: number | null,
|
voltage_mv: number | null,
|
||||||
): VFPoint | null {
|
): VFPoint | null {
|
||||||
if (voltage_mv == null || points.length === 0) return null;
|
if (voltage_mv == null || points.length === 0) return null;
|
||||||
return points.reduce((best, p) =>
|
return points.reduce((best, p) =>
|
||||||
Math.abs(p.volt_mv - voltage_mv) < Math.abs(best.volt_mv - voltage_mv) ? p : best,
|
Math.abs(p.volt_mv - voltage_mv) < Math.abs(best.volt_mv - voltage_mv)
|
||||||
);
|
? p
|
||||||
|
: best,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Voltage domain extent, with padding */
|
/** Voltage domain extent, with padding */
|
||||||
export function voltExtent(points: VFPoint[], padMv = 20): [number, number] {
|
export function voltExtent(points: VFPoint[], padMv = 20): [number, number] {
|
||||||
if (points.length === 0) return [600, 1100];
|
if (points.length === 0) return [600, 1100];
|
||||||
const min = Math.min(...points.map((p) => p.volt_mv));
|
const min = Math.min(...points.map((p) => p.volt_mv));
|
||||||
const max = Math.max(...points.map((p) => p.volt_mv));
|
const max = Math.max(...points.map((p) => p.volt_mv));
|
||||||
return [min - padMv, max + padMv];
|
return [min - padMv, max + padMv];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Frequency domain extent for the effective (boosted) curve, with padding */
|
/** Frequency domain extent for the effective (boosted) curve, with padding */
|
||||||
export function freqExtent(points: VFPoint[], padMhz = 50): [number, number] {
|
export function freqExtent(points: VFPoint[], padMhz = 50): [number, number] {
|
||||||
if (points.length === 0) return [1000, 3000];
|
if (points.length === 0) return [1000, 3000];
|
||||||
const allFreqs = points.flatMap((p) => [p.freq_mhz, refBaseMhz(p)]);
|
const allFreqs = points.flatMap((p) => [p.freq_mhz, refBaseMhz(p)]);
|
||||||
const min = Math.min(...allFreqs);
|
const min = Math.min(...allFreqs);
|
||||||
const max = Math.max(...points.map((p) => p.freq_mhz));
|
const max = Math.max(...points.map((p) => p.freq_mhz));
|
||||||
return [min - padMhz, max + padMhz];
|
return [min - padMhz, max + padMhz];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,19 +61,19 @@ export function freqExtent(points: VFPoint[], padMhz = 50): [number, number] {
|
|||||||
* point 104 has +315 MHz → effective also 3907 MHz (clamped).
|
* point 104 has +315 MHz → effective also 3907 MHz (clamped).
|
||||||
*/
|
*/
|
||||||
export function detectClampedPoints(points: VFPoint[]): Set<number> {
|
export function detectClampedPoints(points: VFPoint[]): Set<number> {
|
||||||
const clamped = new Set<number>();
|
const clamped = new Set<number>();
|
||||||
let ceiling = -Infinity;
|
let ceiling = -Infinity;
|
||||||
let ceilingOffset = -Infinity;
|
let ceilingOffset = -Infinity;
|
||||||
|
|
||||||
for (const p of points) {
|
for (const p of points) {
|
||||||
if (p.freq_mhz <= ceiling && p.delta_khz < ceilingOffset) {
|
if (p.freq_mhz <= ceiling && p.delta_khz < ceilingOffset) {
|
||||||
clamped.add(p.index);
|
clamped.add(p.index);
|
||||||
}
|
|
||||||
if (p.freq_mhz > ceiling) {
|
|
||||||
ceiling = p.freq_mhz;
|
|
||||||
ceilingOffset = p.delta_khz;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (p.freq_mhz > ceiling) {
|
||||||
|
ceiling = p.freq_mhz;
|
||||||
|
ceilingOffset = p.delta_khz;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return clamped;
|
return clamped;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Custom hatchling build hook: build the React frontend if it is missing or stale.
|
||||||
|
|
||||||
|
This makes NVCurve installable with a single command, e.g.::
|
||||||
|
|
||||||
|
uv tool install "git+https://gitea.zephyre.one/Pakobbix/nvcurve.git"
|
||||||
|
|
||||||
|
The hook runs inside the isolated build environment right before the wheel
|
||||||
|
(or sdist) is assembled. If ``frontend/dist`` does not exist yet — or is older
|
||||||
|
than the frontend sources — it compiles the frontend using the host's ``npm``
|
||||||
|
(PATH is inherited from the environment).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
||||||
|
|
||||||
|
# Frontend inputs that must be newer than dist/index.html to trigger a rebuild.
|
||||||
|
_FRONTEND_INPUTS = (
|
||||||
|
"src",
|
||||||
|
"index.html",
|
||||||
|
"vite.config.ts",
|
||||||
|
"package.json",
|
||||||
|
"tsconfig.json",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FrontendBuildHook(BuildHookInterface):
|
||||||
|
"""Build ``frontend/dist`` with npm when it is missing or stale."""
|
||||||
|
|
||||||
|
PLUGIN_NAME = "custom"
|
||||||
|
|
||||||
|
def initialize(self, version: str, build_data: dict) -> None:
|
||||||
|
frontend = os.path.join(self.root, "frontend")
|
||||||
|
dist_index = os.path.join(frontend, "dist", "index.html")
|
||||||
|
|
||||||
|
if not self._needs_build(frontend, dist_index):
|
||||||
|
return
|
||||||
|
|
||||||
|
npm = shutil.which("npm")
|
||||||
|
if npm is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"npm not found on PATH. Node.js 18+ and npm are required to build "
|
||||||
|
"the NVCurve frontend. Install them and retry, or use install.sh "
|
||||||
|
"which checks prerequisites for you."
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"[nvcurve] frontend/dist missing or stale — building frontend with npm ...",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
subprocess.run([npm, "ci", "--no-audit", "--no-fund"], cwd=frontend, check=True)
|
||||||
|
subprocess.run([npm, "run", "build"], cwd=frontend, check=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _needs_build(frontend: str, dist_index: str) -> bool:
|
||||||
|
if not os.path.isfile(dist_index):
|
||||||
|
return True
|
||||||
|
dist_mtime = os.path.getmtime(dist_index)
|
||||||
|
for name in _FRONTEND_INPUTS:
|
||||||
|
path = os.path.join(frontend, name)
|
||||||
|
if os.path.isfile(path):
|
||||||
|
if os.path.getmtime(path) > dist_mtime:
|
||||||
|
return True
|
||||||
|
elif os.path.isdir(path):
|
||||||
|
for root, _dirs, files in os.walk(path):
|
||||||
|
for file in files:
|
||||||
|
if os.path.getmtime(os.path.join(root, file)) > dist_mtime:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
Executable
+62
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# NVCurve single-command installer.
|
||||||
|
#
|
||||||
|
# curl -fsSL https://gitea.zephyre.one/Pakobbix/nvcurve/raw/branch/main/install.sh | bash
|
||||||
|
#
|
||||||
|
# or from a local clone:
|
||||||
|
#
|
||||||
|
# git clone https://gitea.zephyre.one/Pakobbix/nvcurve.git && cd nvcurve && ./install.sh
|
||||||
|
#
|
||||||
|
# The React frontend is compiled automatically during the Python build
|
||||||
|
# (see hatch_build.py), so Node.js 18+ and npm must be available.
|
||||||
|
#
|
||||||
|
# Environment:
|
||||||
|
# NVCURVE_BRANCH branch to install (default: main)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_URL="https://gitea.zephyre.one/Pakobbix/nvcurve.git"
|
||||||
|
BRANCH="${NVCURVE_BRANCH:-main}"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "error: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- prerequisites -----------------------------------------------------------
|
||||||
|
command -v git >/dev/null 2>&1 ||
|
||||||
|
fail "git is required. Install it first."
|
||||||
|
command -v node >/dev/null 2>&1 ||
|
||||||
|
fail "Node.js 18+ is required (e.g. 'sudo pacman -S nodejs npm' or 'sudo apt install nodejs npm')."
|
||||||
|
command -v npm >/dev/null 2>&1 ||
|
||||||
|
fail "npm is required (usually installed together with Node.js)."
|
||||||
|
|
||||||
|
if ! command -v uv >/dev/null 2>&1; then
|
||||||
|
echo "uv not found — installing it from https://astral.sh/uv ..."
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
command -v uv >/dev/null 2>&1 ||
|
||||||
|
fail "uv installation failed. Install uv manually: https://docs.astral.sh/uv/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- locate the source tree ---------------------------------------------------
|
||||||
|
if [ -f pyproject.toml ] && [ -d frontend ]; then
|
||||||
|
src="$(pwd)"
|
||||||
|
echo "Installing from current directory: $src"
|
||||||
|
else
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$tmp"' EXIT
|
||||||
|
echo "Cloning $REPO_URL (branch: $BRANCH) ..."
|
||||||
|
git clone --quiet --depth 1 --branch "$BRANCH" "$REPO_URL" "$tmp/nvcurve"
|
||||||
|
src="$tmp/nvcurve"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- install -------------------------------------------------------------------
|
||||||
|
# The frontend is built automatically by the build hook (hatch_build.py).
|
||||||
|
uv tool install --force "$src"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "NVCurve installed."
|
||||||
|
echo " Verify your GPU: nvcurve setup"
|
||||||
|
echo " Start the web UI: nvcurve"
|
||||||
+187
-17
@@ -38,7 +38,7 @@ import sys
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from .client import ApiError, NvCurveClient, ServerNotRunning
|
from .client import ApiError, NvCurveClient, ServerNotRunning
|
||||||
from .config import Config, default_config
|
from .config import Config, default_config, normalize_trusted_proxies, tls_enabled
|
||||||
from .nvapi.constants import (
|
from .nvapi.constants import (
|
||||||
CT_BASE,
|
CT_BASE,
|
||||||
CT_POINTS,
|
CT_POINTS,
|
||||||
@@ -405,6 +405,11 @@ def run_diagnostics(gpu, gpu_name, gpu_index: int = 0):
|
|||||||
print(f" Default: {fmt_w(def_w)}")
|
print(f" Default: {fmt_w(def_w)}")
|
||||||
if min_w is not None and max_w is not None:
|
if min_w is not None and max_w is not None:
|
||||||
print(f" Range: {min_w} – {max_w} W")
|
print(f" Range: {min_w} – {max_w} W")
|
||||||
|
if pwr.get("rm_power_supported"):
|
||||||
|
print(
|
||||||
|
" Experimental RM power: available (opt-in via web UI or profile;"
|
||||||
|
" extends range to 30 W)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── Privilege / browser helpers ───────────────────────────────────────────────
|
# ── Privilege / browser helpers ───────────────────────────────────────────────
|
||||||
@@ -472,6 +477,21 @@ _PERSISTENT_CONFIG_FILE = (
|
|||||||
)
|
)
|
||||||
_DAEMON_SOCKET_PATH = "/run/nvcurve-daemon.sock"
|
_DAEMON_SOCKET_PATH = "/run/nvcurve-daemon.sock"
|
||||||
|
|
||||||
|
|
||||||
|
def _configured_max_delta() -> int:
|
||||||
|
"""Frequency safety cap from the persistent config (built-in default fallback).
|
||||||
|
|
||||||
|
The operator-configured cap is authoritative for all direct-hardware
|
||||||
|
write paths (write, profile apply, verify) unless explicitly overridden
|
||||||
|
with --max-delta.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(_PERSISTENT_CONFIG_FILE) as f:
|
||||||
|
return json.load(f).get("max_delta_khz", default_config.max_delta_khz)
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||||
|
return default_config.max_delta_khz
|
||||||
|
|
||||||
|
|
||||||
_ALLOWED_HOSTS = {"127.0.0.1", "::1", "localhost"}
|
_ALLOWED_HOSTS = {"127.0.0.1", "::1", "localhost"}
|
||||||
|
|
||||||
|
|
||||||
@@ -548,12 +568,18 @@ def _discover_server_url(cfg: Config) -> str:
|
|||||||
1. /run/nvcurve.json — runtime info written by the running server process
|
1. /run/nvcurve.json — runtime info written by the running server process
|
||||||
2. /etc/nvcurve/config.json — persistent config written by `service install`
|
2. /etc/nvcurve/config.json — persistent config written by `service install`
|
||||||
3. Config defaults — 127.0.0.1:8042
|
3. Config defaults — 127.0.0.1:8042
|
||||||
|
|
||||||
|
The scheme is https when TLS is configured (or reported by the running
|
||||||
|
server), http otherwise.
|
||||||
"""
|
"""
|
||||||
|
scheme = "https" if tls_enabled(cfg) else "http"
|
||||||
|
|
||||||
# 1. Runtime info (most accurate — reflects the actual running port)
|
# 1. Runtime info (most accurate — reflects the actual running port)
|
||||||
info = _read_server_info()
|
info = _read_server_info()
|
||||||
if info:
|
if info:
|
||||||
host = _safe_host(info["host"], cfg)
|
host = _safe_host(info["host"], cfg)
|
||||||
return f"http://{host}:{info['port']}"
|
s = "https" if info.get("tls") else scheme
|
||||||
|
return f"{s}://{host}:{info['port']}"
|
||||||
|
|
||||||
# 2. Persistent config (survives reboots; written by `service install`)
|
# 2. Persistent config (survives reboots; written by `service install`)
|
||||||
try:
|
try:
|
||||||
@@ -561,12 +587,12 @@ def _discover_server_url(cfg: Config) -> str:
|
|||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
host = _safe_host(data.get("host", cfg.host), cfg)
|
host = _safe_host(data.get("host", cfg.host), cfg)
|
||||||
port = data.get("port", cfg.port)
|
port = data.get("port", cfg.port)
|
||||||
return f"http://{host}:{port}"
|
return f"{scheme}://{host}:{port}"
|
||||||
except (FileNotFoundError, json.JSONDecodeError, KeyError):
|
except (FileNotFoundError, json.JSONDecodeError, KeyError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# 3. Hardcoded defaults
|
# 3. Hardcoded defaults
|
||||||
return f"http://{cfg.host}:{cfg.port}"
|
return f"{scheme}://{cfg.host}:{cfg.port}"
|
||||||
|
|
||||||
|
|
||||||
# ── Subcommand handlers ───────────────────────────────────────────────────────
|
# ── Subcommand handlers ───────────────────────────────────────────────────────
|
||||||
@@ -823,7 +849,7 @@ def cmd_write(args):
|
|||||||
}
|
}
|
||||||
|
|
||||||
effective_max = (
|
effective_max = (
|
||||||
max_delta_khz if max_delta_khz is not None else default_config.max_delta_khz
|
max_delta_khz if max_delta_khz is not None else _configured_max_delta()
|
||||||
)
|
)
|
||||||
errors = validate_write(point_deltas, effective_max)
|
errors = validate_write(point_deltas, effective_max)
|
||||||
if errors:
|
if errors:
|
||||||
@@ -864,6 +890,7 @@ def cmd_verify(args):
|
|||||||
from .hal.gpu import get_gpu
|
from .hal.gpu import get_gpu
|
||||||
from .hal.snapshot import save as snapshot_save
|
from .hal.snapshot import save as snapshot_save
|
||||||
from .hal.vfcurve import read_clock_offsets, write_offsets
|
from .hal.vfcurve import read_clock_offsets, write_offsets
|
||||||
|
from .safety import validate_write
|
||||||
|
|
||||||
try:
|
try:
|
||||||
delta_khz = int(args.delta * 1000)
|
delta_khz = int(args.delta * 1000)
|
||||||
@@ -881,6 +908,13 @@ def cmd_verify(args):
|
|||||||
|
|
||||||
point_deltas = dict.fromkeys(points, delta_khz)
|
point_deltas = dict.fromkeys(points, delta_khz)
|
||||||
|
|
||||||
|
# Enforce the operator-configured safety cap (same as cmd_write).
|
||||||
|
errors = validate_write(point_deltas, _configured_max_delta())
|
||||||
|
if errors:
|
||||||
|
for e in errors:
|
||||||
|
print(f"Error: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
gpu, gpu_name = get_gpu(index=getattr(args, "gpu_index", 0))
|
gpu, gpu_name = get_gpu(index=getattr(args, "gpu_index", 0))
|
||||||
|
|
||||||
print("=== Write-Verify Cycle ===")
|
print("=== Write-Verify Cycle ===")
|
||||||
@@ -1178,12 +1212,33 @@ def cmd_profile(args):
|
|||||||
power_limit_w = None
|
power_limit_w = None
|
||||||
mem_offset_mhz = None
|
mem_offset_mhz = None
|
||||||
|
|
||||||
|
# Capture the GPU's power-cap mode: prefer the running server (most
|
||||||
|
# current), else fall back to the persisted per-GPU mode from config
|
||||||
|
# (so a profile saved while the server is down or auth is enabled
|
||||||
|
# still records the GPU's actual mode rather than assuming nvml).
|
||||||
|
power_cap_mode = "nvml"
|
||||||
|
try:
|
||||||
|
from .client import NvCurveClient
|
||||||
|
|
||||||
|
base = getattr(args, "server", None) or _discover_server_url(default_config)
|
||||||
|
limits = NvCurveClient(base=base, gpu_index=gpu_index).limits()
|
||||||
|
if limits.get("power_cap_mode") in ("nvml", "ioctl"):
|
||||||
|
power_cap_mode = limits["power_cap_mode"]
|
||||||
|
except Exception as exc:
|
||||||
|
log.debug("Could not read power-cap mode from server: %s", exc)
|
||||||
|
gpu_key = _gpu_stable_key_offline(gpu_index)
|
||||||
|
if gpu_key is not None:
|
||||||
|
persisted = default_config.power_cap_modes.get(gpu_key)
|
||||||
|
if persisted in ("nvml", "ioctl"):
|
||||||
|
power_cap_mode = persisted
|
||||||
|
|
||||||
data = ProfileData(
|
data = ProfileData(
|
||||||
name=args.name,
|
name=args.name,
|
||||||
gpu_name=gpu_name,
|
gpu_name=gpu_name,
|
||||||
curve_deltas=curve_deltas,
|
curve_deltas=curve_deltas,
|
||||||
mem_offset_mhz=mem_offset_mhz,
|
mem_offset_mhz=mem_offset_mhz,
|
||||||
power_limit_w=power_limit_w,
|
power_limit_w=power_limit_w,
|
||||||
|
power_cap_mode=power_cap_mode,
|
||||||
)
|
)
|
||||||
filepath = save_profile(default_config.profile_dir, data)
|
filepath = save_profile(default_config.profile_dir, data)
|
||||||
print(f"Saved profile '{args.name}' to {filepath}")
|
print(f"Saved profile '{args.name}' to {filepath}")
|
||||||
@@ -1220,7 +1275,8 @@ def cmd_profile(args):
|
|||||||
errs.append(f"Mem offset: {msg}")
|
errs.append(f"Mem offset: {msg}")
|
||||||
|
|
||||||
if profile.power_limit_w is not None:
|
if profile.power_limit_w is not None:
|
||||||
ok, msg = set_power_limit(profile.power_limit_w, gpu_index)
|
mode = profile.power_cap_mode or "nvml"
|
||||||
|
ok, msg = set_power_limit(profile.power_limit_w, gpu_index, mode)
|
||||||
if not ok:
|
if not ok:
|
||||||
errs.append(f"Power limit: {msg}")
|
errs.append(f"Power limit: {msg}")
|
||||||
|
|
||||||
@@ -1233,7 +1289,7 @@ def cmd_profile(args):
|
|||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
errors = validate_write(deltas, default_config.max_delta_khz)
|
errors = validate_write(deltas, _configured_max_delta())
|
||||||
if errors:
|
if errors:
|
||||||
errs.append("Curve: " + "; ".join(errors))
|
errs.append("Curve: " + "; ".join(errors))
|
||||||
else:
|
else:
|
||||||
@@ -1571,6 +1627,10 @@ def cmd_service(args):
|
|||||||
port = getattr(args, "port", 8042)
|
port = getattr(args, "port", 8042)
|
||||||
auto_serve = getattr(args, "auto_serve", False)
|
auto_serve = getattr(args, "auto_serve", False)
|
||||||
persistent_cfg.update({"host": host, "port": port, "auto_serve": auto_serve})
|
persistent_cfg.update({"host": host, "port": port, "auto_serve": auto_serve})
|
||||||
|
if getattr(args, "ssl_certfile", None):
|
||||||
|
persistent_cfg["ssl_certfile"] = args.ssl_certfile
|
||||||
|
if getattr(args, "ssl_keyfile", None):
|
||||||
|
persistent_cfg["ssl_keyfile"] = args.ssl_keyfile
|
||||||
try:
|
try:
|
||||||
with open(_PERSISTENT_CONFIG_FILE, "w") as f:
|
with open(_PERSISTENT_CONFIG_FILE, "w") as f:
|
||||||
json.dump(persistent_cfg, f, indent=2)
|
json.dump(persistent_cfg, f, indent=2)
|
||||||
@@ -1578,11 +1638,17 @@ def cmd_service(args):
|
|||||||
print(f"Failed to write {_PERSISTENT_CONFIG_FILE}: {exc}", file=sys.stderr)
|
print(f"Failed to write {_PERSISTENT_CONFIG_FILE}: {exc}", file=sys.stderr)
|
||||||
return
|
return
|
||||||
print(f"Persistent config written to {_PERSISTENT_CONFIG_FILE}")
|
print(f"Persistent config written to {_PERSISTENT_CONFIG_FILE}")
|
||||||
|
scheme = (
|
||||||
|
"https"
|
||||||
|
if persistent_cfg.get("ssl_certfile") and persistent_cfg.get("ssl_keyfile")
|
||||||
|
else "http"
|
||||||
|
)
|
||||||
if auto_serve:
|
if auto_serve:
|
||||||
print(f" Web server will auto-start on boot at {host}:{port}")
|
print(f" Web server will auto-start on boot at {scheme}://{host}:{port}")
|
||||||
else:
|
else:
|
||||||
print(
|
print(
|
||||||
f" Web server default: {host}:{port} (start on demand: nvcurve serve start)"
|
f" Web server default: {scheme}://{host}:{port} "
|
||||||
|
"(start on demand: nvcurve serve start)"
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -1706,12 +1772,14 @@ def cmd_service(args):
|
|||||||
auto_serve = pcfg.get("auto_serve", False)
|
auto_serve = pcfg.get("auto_serve", False)
|
||||||
host = pcfg.get("host", "127.0.0.1")
|
host = pcfg.get("host", "127.0.0.1")
|
||||||
port = pcfg.get("port", 8042)
|
port = pcfg.get("port", 8042)
|
||||||
|
tls = bool(pcfg.get("ssl_certfile") and pcfg.get("ssl_keyfile"))
|
||||||
print()
|
print()
|
||||||
print(f"web server auto-start: {'on' if auto_serve else 'off'}")
|
print(f"web server auto-start: {'on' if auto_serve else 'off'}")
|
||||||
print(f"web server address: {host}:{port}")
|
print(f"web server address: {'https' if tls else 'http'}://{host}:{port}")
|
||||||
|
print(f"web server TLS: {'on' if tls else 'off'}")
|
||||||
print()
|
print()
|
||||||
print(
|
print(
|
||||||
"Change with: nvcurve service configure [--auto-serve|--no-auto-serve] [--host H] [--port P]"
|
"Change with: nvcurve service configure [--auto-serve|--no-auto-serve] [--host H] [--port P] [--ssl-certfile C --ssl-keyfile K]"
|
||||||
)
|
)
|
||||||
|
|
||||||
elif action == "configure":
|
elif action == "configure":
|
||||||
@@ -1736,6 +1804,13 @@ def cmd_service(args):
|
|||||||
pcfg["host"] = args.host
|
pcfg["host"] = args.host
|
||||||
if hasattr(args, "port") and args.port is not None:
|
if hasattr(args, "port") and args.port is not None:
|
||||||
pcfg["port"] = args.port
|
pcfg["port"] = args.port
|
||||||
|
if getattr(args, "ssl_certfile", None):
|
||||||
|
pcfg["ssl_certfile"] = args.ssl_certfile
|
||||||
|
if getattr(args, "ssl_keyfile", None):
|
||||||
|
pcfg["ssl_keyfile"] = args.ssl_keyfile
|
||||||
|
if getattr(args, "no_ssl", False):
|
||||||
|
pcfg.pop("ssl_certfile", None)
|
||||||
|
pcfg.pop("ssl_keyfile", None)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(_PERSISTENT_CONFIG_FILE, "w") as f:
|
with open(_PERSISTENT_CONFIG_FILE, "w") as f:
|
||||||
@@ -1747,6 +1822,9 @@ def cmd_service(args):
|
|||||||
print(f" auto-serve: {'on' if pcfg.get('auto_serve', False) else 'off'}")
|
print(f" auto-serve: {'on' if pcfg.get('auto_serve', False) else 'off'}")
|
||||||
print(f" host: {pcfg.get('host', '127.0.0.1')}")
|
print(f" host: {pcfg.get('host', '127.0.0.1')}")
|
||||||
print(f" port: {pcfg.get('port', 8042)}")
|
print(f" port: {pcfg.get('port', 8042)}")
|
||||||
|
print(
|
||||||
|
f" TLS: {'on' if pcfg.get('ssl_certfile') and pcfg.get('ssl_keyfile') else 'off'}"
|
||||||
|
)
|
||||||
|
|
||||||
if os.path.exists(unit_path):
|
if os.path.exists(unit_path):
|
||||||
try:
|
try:
|
||||||
@@ -1766,12 +1844,28 @@ def _cmd_serve_start(args, cfg: Config, open_browser: bool = False) -> None:
|
|||||||
host = getattr(args, "host", cfg.host)
|
host = getattr(args, "host", cfg.host)
|
||||||
port = getattr(args, "port", cfg.port)
|
port = getattr(args, "port", cfg.port)
|
||||||
|
|
||||||
|
# Optional TLS (CLI flags override the persistent config).
|
||||||
|
ssl_certfile = getattr(args, "ssl_certfile", None)
|
||||||
|
ssl_keyfile = getattr(args, "ssl_keyfile", None)
|
||||||
|
if ssl_certfile:
|
||||||
|
cfg.ssl_certfile = ssl_certfile
|
||||||
|
if ssl_keyfile:
|
||||||
|
cfg.ssl_keyfile = ssl_keyfile
|
||||||
|
|
||||||
# --direct: skip daemon round-trip (used when the daemon itself spawns us).
|
# --direct: skip daemon round-trip (used when the daemon itself spawns us).
|
||||||
if getattr(args, "direct", False):
|
if getattr(args, "direct", False):
|
||||||
require_root()
|
require_root()
|
||||||
try:
|
try:
|
||||||
with open(_SERVER_INFO_FILE, "w") as f:
|
with open(_SERVER_INFO_FILE, "w") as f:
|
||||||
json.dump({"pid": os.getpid(), "host": host, "port": port}, f)
|
json.dump(
|
||||||
|
{
|
||||||
|
"pid": os.getpid(),
|
||||||
|
"host": host,
|
||||||
|
"port": port,
|
||||||
|
"tls": tls_enabled(cfg),
|
||||||
|
},
|
||||||
|
f,
|
||||||
|
)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
print(f"Failed to write {_SERVER_INFO_FILE}: {exc}", file=sys.stderr)
|
print(f"Failed to write {_SERVER_INFO_FILE}: {exc}", file=sys.stderr)
|
||||||
return
|
return
|
||||||
@@ -1791,13 +1885,34 @@ def _cmd_serve_start(args, cfg: Config, open_browser: bool = False) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Prefer daemon socket: no root required, daemon manages the server process.
|
# Prefer daemon socket: no root required, daemon manages the server process.
|
||||||
resp = _daemon_send({"cmd": "serve_start", "host": host, "port": port})
|
# The daemon always binds the configured host/port (callers cannot choose
|
||||||
|
# the interface), so report the address from the daemon's response.
|
||||||
|
resp = _daemon_send({"cmd": "serve_start"})
|
||||||
if resp is not None:
|
if resp is not None:
|
||||||
if resp.get("ok"):
|
if resp.get("ok"):
|
||||||
print(f"Web server starting (PID {resp['pid']}) at http://{host}:{port}")
|
rhost = resp.get("host", host)
|
||||||
|
rport = resp.get("port", port)
|
||||||
|
if (rhost, rport) != (host, port):
|
||||||
|
print(
|
||||||
|
f"Note: daemon uses the configured bind address {rhost}:{rport} "
|
||||||
|
"(change with: nvcurve service configure --host/--port)",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
if (ssl_certfile or ssl_keyfile) and not resp.get("tls"):
|
||||||
|
print(
|
||||||
|
"Note: --ssl-certfile/--ssl-keyfile are ignored while the daemon "
|
||||||
|
"manages the server — the daemon uses the TLS settings from "
|
||||||
|
"/etc/nvcurve/config.json (set with: nvcurve service configure "
|
||||||
|
"--ssl-certfile/--ssl-keyfile)",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
scheme = "https" if resp.get("tls") else "http"
|
||||||
|
print(
|
||||||
|
f"Web server starting (PID {resp['pid']}) at {scheme}://{rhost}:{rport}"
|
||||||
|
)
|
||||||
if open_browser:
|
if open_browser:
|
||||||
time.sleep(1.5)
|
time.sleep(1.5)
|
||||||
_open_browser_as_user(f"http://{host}:{port}")
|
_open_browser_as_user(f"{scheme}://{rhost}:{rport}")
|
||||||
else:
|
else:
|
||||||
print(f"Daemon: {resp.get('error')}", file=sys.stderr)
|
print(f"Daemon: {resp.get('error')}", file=sys.stderr)
|
||||||
return
|
return
|
||||||
@@ -1807,7 +1922,8 @@ def _cmd_serve_start(args, cfg: Config, open_browser: bool = False) -> None:
|
|||||||
|
|
||||||
info = _read_server_info()
|
info = _read_server_info()
|
||||||
if info:
|
if info:
|
||||||
url = f"http://{info['host']}:{info['port']}"
|
scheme = "https" if info.get("tls") else "http"
|
||||||
|
url = f"{scheme}://{info['host']}:{info['port']}"
|
||||||
print(f"Server is already running (PID {info['pid']}) at {url}.")
|
print(f"Server is already running (PID {info['pid']}) at {url}.")
|
||||||
if open_browser:
|
if open_browser:
|
||||||
_open_browser_as_user(url)
|
_open_browser_as_user(url)
|
||||||
@@ -1827,6 +1943,10 @@ def _cmd_serve_start(args, cfg: Config, open_browser: bool = False) -> None:
|
|||||||
"--port",
|
"--port",
|
||||||
str(port),
|
str(port),
|
||||||
]
|
]
|
||||||
|
if ssl_certfile:
|
||||||
|
cmd += ["--ssl-certfile", ssl_certfile]
|
||||||
|
if ssl_keyfile:
|
||||||
|
cmd += ["--ssl-keyfile", ssl_keyfile]
|
||||||
if getattr(args, "gpu_index", 0):
|
if getattr(args, "gpu_index", 0):
|
||||||
cmd += ["--gpu", str(args.gpu_index)]
|
cmd += ["--gpu", str(args.gpu_index)]
|
||||||
log_path = _log_file()
|
log_path = _log_file()
|
||||||
@@ -1846,7 +1966,15 @@ def _cmd_serve_start(args, cfg: Config, open_browser: bool = False) -> None:
|
|||||||
# Foreground mode — write info file so clients can discover host:port.
|
# Foreground mode — write info file so clients can discover host:port.
|
||||||
try:
|
try:
|
||||||
with open(_SERVER_INFO_FILE, "w") as f:
|
with open(_SERVER_INFO_FILE, "w") as f:
|
||||||
json.dump({"pid": os.getpid(), "host": host, "port": port}, f)
|
json.dump(
|
||||||
|
{
|
||||||
|
"pid": os.getpid(),
|
||||||
|
"host": host,
|
||||||
|
"port": port,
|
||||||
|
"tls": tls_enabled(cfg),
|
||||||
|
},
|
||||||
|
f,
|
||||||
|
)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
print(f"Failed to write {_SERVER_INFO_FILE}: {exc}", file=sys.stderr)
|
print(f"Failed to write {_SERVER_INFO_FILE}: {exc}", file=sys.stderr)
|
||||||
return
|
return
|
||||||
@@ -2056,6 +2184,12 @@ Examples:
|
|||||||
"--host", default="127.0.0.1", help="Bind address (default 127.0.0.1)"
|
"--host", default="127.0.0.1", help="Bind address (default 127.0.0.1)"
|
||||||
)
|
)
|
||||||
p_start.add_argument("--port", type=int, default=8042, help="Port (default 8042)")
|
p_start.add_argument("--port", type=int, default=8042, help="Port (default 8042)")
|
||||||
|
p_start.add_argument(
|
||||||
|
"--ssl-certfile", default=None, help="TLS certificate (enables HTTPS)"
|
||||||
|
)
|
||||||
|
p_start.add_argument(
|
||||||
|
"--ssl-keyfile", default=None, help="TLS private key (enables HTTPS)"
|
||||||
|
)
|
||||||
p_start.add_argument(
|
p_start.add_argument(
|
||||||
"--detach", "-d", action="store_true", help="Run in background"
|
"--detach", "-d", action="store_true", help="Run in background"
|
||||||
)
|
)
|
||||||
@@ -2090,6 +2224,16 @@ Examples:
|
|||||||
default=8042,
|
default=8042,
|
||||||
help="Default web server port (stored in config)",
|
help="Default web server port (stored in config)",
|
||||||
)
|
)
|
||||||
|
p_install.add_argument(
|
||||||
|
"--ssl-certfile",
|
||||||
|
default=None,
|
||||||
|
help="TLS certificate (stored in config; enables HTTPS)",
|
||||||
|
)
|
||||||
|
p_install.add_argument(
|
||||||
|
"--ssl-keyfile",
|
||||||
|
default=None,
|
||||||
|
help="TLS private key (stored in config; enables HTTPS)",
|
||||||
|
)
|
||||||
|
|
||||||
p_configure = s_svc.add_parser(
|
p_configure = s_svc.add_parser(
|
||||||
"configure", help="Update config and restart daemon (escalates to root)"
|
"configure", help="Update config and restart daemon (escalates to root)"
|
||||||
@@ -2109,6 +2253,19 @@ Examples:
|
|||||||
)
|
)
|
||||||
p_configure.add_argument("--host", default=None, help="Web server bind address")
|
p_configure.add_argument("--host", default=None, help="Web server bind address")
|
||||||
p_configure.add_argument("--port", type=int, default=None, help="Web server port")
|
p_configure.add_argument("--port", type=int, default=None, help="Web server port")
|
||||||
|
p_configure.add_argument(
|
||||||
|
"--ssl-certfile",
|
||||||
|
default=None,
|
||||||
|
help="TLS certificate (stored in config; enables HTTPS)",
|
||||||
|
)
|
||||||
|
p_configure.add_argument(
|
||||||
|
"--ssl-keyfile", default=None, help="TLS private key (stored in config)"
|
||||||
|
)
|
||||||
|
p_configure.add_argument(
|
||||||
|
"--no-ssl",
|
||||||
|
action="store_true",
|
||||||
|
help="Disable TLS (remove certificate/key from config)",
|
||||||
|
)
|
||||||
|
|
||||||
s_svc.add_parser("uninstall", help="Remove systemd service (escalates to root)")
|
s_svc.add_parser("uninstall", help="Remove systemd service (escalates to root)")
|
||||||
s_svc.add_parser("start", help="Start systemd service (escalates to root)")
|
s_svc.add_parser("start", help="Start systemd service (escalates to root)")
|
||||||
@@ -2149,9 +2306,14 @@ def main():
|
|||||||
"users_file",
|
"users_file",
|
||||||
"host",
|
"host",
|
||||||
"port",
|
"port",
|
||||||
|
"ssl_certfile",
|
||||||
|
"ssl_keyfile",
|
||||||
|
"allow_api_shutdown",
|
||||||
):
|
):
|
||||||
if key in data:
|
if key in data:
|
||||||
setattr(cfg, key, data[key])
|
setattr(cfg, key, data[key])
|
||||||
|
if "trusted_proxies" in data:
|
||||||
|
cfg.trusted_proxies = normalize_trusted_proxies(data["trusted_proxies"])
|
||||||
if "auto_load_profiles" in data:
|
if "auto_load_profiles" in data:
|
||||||
# Keys are stable GPU identifiers (UUID, "pci:XXXX", or "idx:N")
|
# Keys are stable GPU identifiers (UUID, "pci:XXXX", or "idx:N")
|
||||||
cfg.auto_load_profiles = dict(data["auto_load_profiles"])
|
cfg.auto_load_profiles = dict(data["auto_load_profiles"])
|
||||||
@@ -2161,6 +2323,14 @@ def main():
|
|||||||
if "fan_curves" in data:
|
if "fan_curves" in data:
|
||||||
# Per-GPU active fan curves, restored on server startup.
|
# Per-GPU active fan curves, restored on server startup.
|
||||||
cfg.fan_curves = dict(data["fan_curves"])
|
cfg.fan_curves = dict(data["fan_curves"])
|
||||||
|
if "power_cap_modes" in data:
|
||||||
|
# Per-GPU experimental power-cap mode. "nvml" is the default
|
||||||
|
# (the server treats it as unset); keep only valid values.
|
||||||
|
cfg.power_cap_modes = {
|
||||||
|
str(k): str(v)
|
||||||
|
for k, v in dict(data["power_cap_modes"]).items()
|
||||||
|
if str(v) in ("nvml", "ioctl")
|
||||||
|
}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.debug("Could not load user config: %s", exc)
|
log.debug("Could not load user config: %s", exc)
|
||||||
|
|
||||||
|
|||||||
+8
-10
@@ -120,18 +120,13 @@ class NvCurveClient:
|
|||||||
def write_curve(
|
def write_curve(
|
||||||
self,
|
self,
|
||||||
deltas: dict[int, int],
|
deltas: dict[int, int],
|
||||||
max_delta_khz: int | None = None,
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
body: dict = {"deltas": deltas}
|
# The server enforces its configured safety cap; clients cannot
|
||||||
if max_delta_khz is not None:
|
# override it per request.
|
||||||
body["max_delta_khz"] = max_delta_khz
|
return self._post("/api/curve/write", {"deltas": deltas})
|
||||||
return self._post("/api/curve/write", body)
|
|
||||||
|
|
||||||
def write_global(self, delta_khz: int, max_delta_khz: int | None = None) -> dict:
|
def write_global(self, delta_khz: int) -> dict:
|
||||||
body: dict = {"delta_khz": delta_khz}
|
return self._post("/api/curve/write/global", {"delta_khz": delta_khz})
|
||||||
if max_delta_khz is not None:
|
|
||||||
body["max_delta_khz"] = max_delta_khz
|
|
||||||
return self._post("/api/curve/write/global", body)
|
|
||||||
|
|
||||||
def reset_curve(self) -> dict:
|
def reset_curve(self) -> dict:
|
||||||
return self._post("/api/curve/reset")
|
return self._post("/api/curve/reset")
|
||||||
@@ -150,6 +145,9 @@ class NvCurveClient:
|
|||||||
def snapshots(self) -> list:
|
def snapshots(self) -> list:
|
||||||
return self._get("/api/snapshots")
|
return self._get("/api/snapshots")
|
||||||
|
|
||||||
|
def limits(self) -> dict:
|
||||||
|
return self._get("/api/limits")
|
||||||
|
|
||||||
# ── Profiles ─────────────────────────────────────────────────────────────
|
# ── Profiles ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def profiles(self) -> dict:
|
def profiles(self) -> dict:
|
||||||
|
|||||||
@@ -17,6 +17,21 @@ class Config:
|
|||||||
host: str = "127.0.0.1"
|
host: str = "127.0.0.1"
|
||||||
port: int = 8042
|
port: int = 8042
|
||||||
|
|
||||||
|
# Optional TLS: when both are set, the server serves HTTPS and the
|
||||||
|
# session cookie is marked Secure. Off by default (plain HTTP).
|
||||||
|
ssl_certfile: str | None = None
|
||||||
|
ssl_keyfile: str | None = None
|
||||||
|
|
||||||
|
# Proxy IPs (e.g. a reverse proxy on 127.0.0.1) whose X-Forwarded-For
|
||||||
|
# header is trusted for the login brute-force lockout. JSON array in
|
||||||
|
# config.json (a comma-separated string is also accepted and normalized).
|
||||||
|
# Without this, all proxied clients share the proxy's IP.
|
||||||
|
trusted_proxies: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
# Allow any authenticated user to stop the server via POST /api/shutdown.
|
||||||
|
# Set false on shared systems; manage the service via systemd instead.
|
||||||
|
allow_api_shutdown: bool = True
|
||||||
|
|
||||||
snapshot_dir: str = "/var/cache/nvcurve/snapshots"
|
snapshot_dir: str = "/var/cache/nvcurve/snapshots"
|
||||||
profile_dir: str = "/etc/nvcurve/profiles"
|
profile_dir: str = "/etc/nvcurve/profiles"
|
||||||
|
|
||||||
@@ -39,6 +54,33 @@ class Config:
|
|||||||
# Legacy entries (bare curve list) are migrated at load time.
|
# Legacy entries (bare curve list) are migrated at load time.
|
||||||
fan_curves: dict[str, object] = field(default_factory=dict)
|
fan_curves: dict[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
# Per-GPU power-cap mode: "nvml" (default, never stored) or "ioctl"
|
||||||
|
# (experimental RM power control — permits caps below the VBIOS minimum).
|
||||||
|
# Key = stable GPU identifier (same as auto_load_profiles).
|
||||||
|
power_cap_modes: dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
# Module-level default config instance.
|
# Module-level default config instance.
|
||||||
default_config = Config()
|
default_config = Config()
|
||||||
|
|
||||||
|
|
||||||
|
def tls_enabled(cfg: Config) -> bool:
|
||||||
|
"""True when both TLS files are configured (server serves HTTPS)."""
|
||||||
|
return bool(cfg.ssl_certfile and cfg.ssl_keyfile)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_trusted_proxies(value) -> list[str]:
|
||||||
|
"""Normalize a trusted_proxies config value to a list of IP strings.
|
||||||
|
|
||||||
|
Accepts a JSON array (the documented format) or a comma-separated string
|
||||||
|
(tolerated for convenience). Normalizing matters because the server does
|
||||||
|
exact list membership tests — a raw string would degrade to substring
|
||||||
|
matching (e.g. "127.0.0.1" in "127.0.0.10").
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if isinstance(value, str):
|
||||||
|
return [h.strip() for h in value.split(",") if h.strip()]
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
return [str(h).strip() for h in value if str(h).strip()]
|
||||||
|
return []
|
||||||
+32
-9
@@ -7,10 +7,16 @@ Protocol: newline-delimited JSON, one request → one response, connection close
|
|||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
{"cmd": "ping"}
|
{"cmd": "ping"}
|
||||||
{"cmd": "serve_start", "host": "127.0.0.1", "port": 8042}
|
{"cmd": "serve_start"}
|
||||||
{"cmd": "serve_stop"}
|
{"cmd": "serve_stop"}
|
||||||
{"cmd": "serve_status"}
|
{"cmd": "serve_status"}
|
||||||
|
|
||||||
|
The socket is world-connectable (unprivileged users drive it via the CLI),
|
||||||
|
so the command surface is deliberately minimal: serve_start ALWAYS binds the
|
||||||
|
configured host/port from /etc/nvcurve/config.json — callers cannot choose
|
||||||
|
the bind address (no ad-hoc 0.0.0.0 exposure). Changing the bind address is
|
||||||
|
an operator action via `nvcurve service configure`.
|
||||||
|
|
||||||
Requires root.
|
Requires root.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -23,7 +29,7 @@ import signal
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from .config import Config
|
from .config import Config, normalize_trusted_proxies
|
||||||
|
|
||||||
log = logging.getLogger("nvcurve.daemon")
|
log = logging.getLogger("nvcurve.daemon")
|
||||||
|
|
||||||
@@ -38,7 +44,13 @@ _cfg: Config | None = None # Config instance, set in run()
|
|||||||
# ── Socket command handlers ────────────────────────────────────────────────────
|
# ── Socket command handlers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def _handle_serve_start(host: str, port: int) -> dict:
|
async def _handle_serve_start() -> dict:
|
||||||
|
"""Start the web server on the *configured* host/port.
|
||||||
|
|
||||||
|
The bind address is taken from /etc/nvcurve/config.json only — the
|
||||||
|
socket is reachable by unprivileged users, so callers must not be able
|
||||||
|
to choose the interface (e.g. binding 0.0.0.0 to expose the API).
|
||||||
|
"""
|
||||||
global _server_proc
|
global _server_proc
|
||||||
if _server_proc is not None and _server_proc.poll() is None:
|
if _server_proc is not None and _server_proc.poll() is None:
|
||||||
return {
|
return {
|
||||||
@@ -47,6 +59,8 @@ async def _handle_serve_start(host: str, port: int) -> dict:
|
|||||||
"pid": _server_proc.pid,
|
"pid": _server_proc.pid,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
host = _cfg.host if _cfg is not None else "127.0.0.1"
|
||||||
|
port = _cfg.port if _cfg is not None else 8042
|
||||||
cmd = [
|
cmd = [
|
||||||
sys.executable,
|
sys.executable,
|
||||||
"-m",
|
"-m",
|
||||||
@@ -72,7 +86,13 @@ async def _handle_serve_start(host: str, port: int) -> dict:
|
|||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
return {"ok": False, "error": f"cannot open log file {log_path}: {exc}"}
|
return {"ok": False, "error": f"cannot open log file {log_path}: {exc}"}
|
||||||
log.info("Web server started (PID %d)", _server_proc.pid)
|
log.info("Web server started (PID %d)", _server_proc.pid)
|
||||||
return {"ok": True, "pid": _server_proc.pid}
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"pid": _server_proc.pid,
|
||||||
|
"host": host,
|
||||||
|
"port": port,
|
||||||
|
"tls": bool(_cfg and _cfg.ssl_certfile and _cfg.ssl_keyfile),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _handle_serve_stop() -> dict:
|
async def _handle_serve_stop() -> dict:
|
||||||
@@ -105,9 +125,7 @@ async def _dispatch(req: dict) -> dict:
|
|||||||
elif cmd == "serve_start":
|
elif cmd == "serve_start":
|
||||||
if _cfg is None:
|
if _cfg is None:
|
||||||
return {"ok": False, "error": "config not initialized"}
|
return {"ok": False, "error": "config not initialized"}
|
||||||
host = req.get("host", _cfg.host)
|
return await _handle_serve_start()
|
||||||
port = req.get("port", _cfg.port)
|
|
||||||
return await _handle_serve_start(host, port)
|
|
||||||
elif cmd == "serve_stop":
|
elif cmd == "serve_stop":
|
||||||
return await _handle_serve_stop()
|
return await _handle_serve_stop()
|
||||||
elif cmd == "serve_status":
|
elif cmd == "serve_status":
|
||||||
@@ -174,9 +192,13 @@ def run() -> None:
|
|||||||
"profile_dir",
|
"profile_dir",
|
||||||
"host",
|
"host",
|
||||||
"port",
|
"port",
|
||||||
|
"ssl_certfile",
|
||||||
|
"ssl_keyfile",
|
||||||
):
|
):
|
||||||
if key in cfg_data:
|
if key in cfg_data:
|
||||||
setattr(_cfg, key, cfg_data[key])
|
setattr(_cfg, key, cfg_data[key])
|
||||||
|
if "trusted_proxies" in cfg_data:
|
||||||
|
_cfg.trusted_proxies = normalize_trusted_proxies(cfg_data["trusted_proxies"])
|
||||||
|
|
||||||
# Apply auto-load profiles in a subprocess so the daemon process itself
|
# Apply auto-load profiles in a subprocess so the daemon process itself
|
||||||
# never loads NvAPI/NVML/HAL modules — keeps steady-state RSS low.
|
# never loads NvAPI/NVML/HAL modules — keeps steady-state RSS low.
|
||||||
@@ -207,7 +229,8 @@ async def _serve_socket(auto_serve: bool = False) -> None:
|
|||||||
server = await asyncio.start_unix_server(_handle_client, path=SOCKET_PATH)
|
server = await asyncio.start_unix_server(_handle_client, path=SOCKET_PATH)
|
||||||
# The socket must be connectable by unprivileged users: the CLI runs as the
|
# The socket must be connectable by unprivileged users: the CLI runs as the
|
||||||
# regular user and talks to this root daemon over the socket. 0o666 is
|
# regular user and talks to this root daemon over the socket. 0o666 is
|
||||||
# intentional (standard for /run daemon sockets).
|
# intentional — the command surface is restricted accordingly (serve_start
|
||||||
|
# always uses the configured host/port; see module docstring).
|
||||||
# pi-lens-ignore: S103
|
# pi-lens-ignore: S103
|
||||||
_SOCKET_MODE = 0o666
|
_SOCKET_MODE = 0o666
|
||||||
os.chmod(
|
os.chmod(
|
||||||
@@ -220,7 +243,7 @@ async def _serve_socket(auto_serve: bool = False) -> None:
|
|||||||
log.warning("auto_serve requested but config not initialized")
|
log.warning("auto_serve requested but config not initialized")
|
||||||
else:
|
else:
|
||||||
log.info("auto_serve enabled — starting web server on boot")
|
log.info("auto_serve enabled — starting web server on boot")
|
||||||
await _handle_serve_start(_cfg.host, _cfg.port)
|
await _handle_serve_start()
|
||||||
|
|
||||||
stop_event = asyncio.Event()
|
stop_event = asyncio.Event()
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
|
|||||||
+51
-6
@@ -59,20 +59,37 @@ def _get_handle(gpu_index: int):
|
|||||||
# ── Power limit ───────────────────────────────────────────────────────────────
|
# ── Power limit ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def get_power_limit(gpu_index: int = 0) -> dict:
|
def get_power_limit(gpu_index: int = 0, mode: str = "nvml") -> dict:
|
||||||
"""Return dict with power_limit_w, default_power_limit_w, min_power_limit_w, max_power_limit_w."""
|
"""Return dict with power limit info.
|
||||||
out: dict[str, int | None] = {
|
|
||||||
|
Keys: power_limit_w, default_power_limit_w, min_power_limit_w,
|
||||||
|
min_power_limit_w_native, max_power_limit_w, rm_power_supported,
|
||||||
|
power_cap_mode.
|
||||||
|
|
||||||
|
mode: "nvml" (default) or "ioctl" (experimental RM power control).
|
||||||
|
min_power_limit_w is the effective minimum: in ioctl mode it is
|
||||||
|
extended to the experimental floor (30 W) when the RM interface is
|
||||||
|
present and validated; min_power_limit_w_native is always the VBIOS
|
||||||
|
minimum. The RM probe is GET-only (no writes) and safe to run on
|
||||||
|
every call.
|
||||||
|
"""
|
||||||
|
out: dict[str, int | bool | str | None] = {
|
||||||
"power_limit_w": None,
|
"power_limit_w": None,
|
||||||
"default_power_limit_w": None,
|
"default_power_limit_w": None,
|
||||||
"min_power_limit_w": None,
|
"min_power_limit_w": None,
|
||||||
|
"min_power_limit_w_native": None,
|
||||||
"max_power_limit_w": None,
|
"max_power_limit_w": None,
|
||||||
|
"rm_power_supported": False,
|
||||||
|
"power_cap_mode": mode,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
handle = _get_handle(gpu_index)
|
handle = _get_handle(gpu_index)
|
||||||
limit = pynvml.nvmlDeviceGetPowerManagementLimit(handle)
|
limit = pynvml.nvmlDeviceGetPowerManagementLimit(handle)
|
||||||
constrs = pynvml.nvmlDeviceGetPowerManagementLimitConstraints(handle)
|
constrs = pynvml.nvmlDeviceGetPowerManagementLimitConstraints(handle)
|
||||||
out["power_limit_w"] = limit // 1000
|
out["power_limit_w"] = limit // 1000
|
||||||
out["min_power_limit_w"] = constrs[0] // 1000
|
native_min = constrs[0] // 1000
|
||||||
|
out["min_power_limit_w"] = native_min
|
||||||
|
out["min_power_limit_w_native"] = native_min
|
||||||
out["max_power_limit_w"] = constrs[1] // 1000
|
out["max_power_limit_w"] = constrs[1] // 1000
|
||||||
try:
|
try:
|
||||||
default = pynvml.nvmlDeviceGetPowerManagementDefaultLimit(handle)
|
default = pynvml.nvmlDeviceGetPowerManagementDefaultLimit(handle)
|
||||||
@@ -81,11 +98,39 @@ def get_power_limit(gpu_index: int = 0) -> dict:
|
|||||||
log.debug("nvmlDeviceGetPowerManagementDefaultLimit: %s", exc)
|
log.debug("nvmlDeviceGetPowerManagementDefaultLimit: %s", exc)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.warning("get_power_limit: %s", exc)
|
log.warning("get_power_limit: %s", exc)
|
||||||
|
return out
|
||||||
|
|
||||||
|
# GET-only RM discovery — reported so the UI can offer the experimental
|
||||||
|
# mode; the effective minimum only changes in ioctl mode.
|
||||||
|
try:
|
||||||
|
from . import rm_power
|
||||||
|
|
||||||
|
bounds = rm_power.probe_gpu(gpu_index)
|
||||||
|
out["rm_power_supported"] = bounds is not None
|
||||||
|
if bounds is not None and mode == "ioctl":
|
||||||
|
out["min_power_limit_w"] = bounds.lower_min_mw() // 1000
|
||||||
|
except Exception as exc:
|
||||||
|
log.debug("RM power probe failed: %s", exc)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def set_power_limit(limit_w: int, gpu_index: int = 0) -> tuple[bool, str]:
|
def set_power_limit(
|
||||||
"""Set the board power limit (Watts)."""
|
limit_w: int, gpu_index: int = 0, mode: str = "nvml"
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
"""Set the board power limit (Watts).
|
||||||
|
|
||||||
|
mode "ioctl" (experimental) applies the limit through the undocumented
|
||||||
|
RM interface, which permits values below the VBIOS minimum. It has no
|
||||||
|
fallback: failures are reported, never silently switched to NVML.
|
||||||
|
"""
|
||||||
|
if mode == "ioctl":
|
||||||
|
from . import rm_power
|
||||||
|
|
||||||
|
try:
|
||||||
|
rm_power.set_power_limit_w(gpu_index, limit_w)
|
||||||
|
return True, "OK"
|
||||||
|
except rm_power.RmPowerError as exc:
|
||||||
|
return False, str(exc)
|
||||||
try:
|
try:
|
||||||
handle = _get_handle(gpu_index)
|
handle = _get_handle(gpu_index)
|
||||||
pynvml.nvmlDeviceSetPowerManagementLimit(handle, limit_w * 1000)
|
pynvml.nvmlDeviceSetPowerManagementLimit(handle, limit_w * 1000)
|
||||||
|
|||||||
@@ -0,0 +1,627 @@
|
|||||||
|
"""Undocumented NVIDIA RM power-limit interface (EXPERIMENTAL).
|
||||||
|
|
||||||
|
Port of the approach from LACT PR #1205 (ilya-zlobintsev/LACT): applies board
|
||||||
|
power limits through the private NV2080 power-limit "ordinary client"
|
||||||
|
interface on /dev/nvidiactl, which permits caps below the VBIOS minimum
|
||||||
|
(down to 30 W). The native maximum still applies.
|
||||||
|
|
||||||
|
EXPERIMENTAL — uses an undocumented driver interface. It may break after
|
||||||
|
driver updates. Discovery is GET-only and validates the RM payload against
|
||||||
|
NVML before any write is issued; a failed write restores the previous
|
||||||
|
request (even if it was below the VBIOS minimum).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import ctypes
|
||||||
|
import fcntl
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
log = logging.getLogger("nvcurve.hal.rm_power")
|
||||||
|
|
||||||
|
# ── ioctl constants (nv-ioctl.h / nv-ioctl-numbers.h) ─────────────────────────
|
||||||
|
|
||||||
|
NV_IOCTL_MAGIC = ord("N") # 0x4E — user-space RM interface
|
||||||
|
NV_ESC_RM_ALLOC = 0x2B
|
||||||
|
NV_ESC_RM_CONTROL = 0x2A
|
||||||
|
|
||||||
|
# 'F' magic interface (kernel-open/common/inc/nv-ioctl-numbers.h) —
|
||||||
|
# NV_ESC_REGISTER_FD lives here, not in the 'N' RM interface.
|
||||||
|
NV_IOCTL_MAGIC_F = ord("F") # 0x46
|
||||||
|
NV_IOCTL_BASE_F = 200
|
||||||
|
NV_ESC_REGISTER_FD = NV_IOCTL_BASE_F + 1 # 201
|
||||||
|
|
||||||
|
# RM class IDs (nv0080.h / nv2080.h)
|
||||||
|
NV01_DEVICE_0 = 0x0080
|
||||||
|
NV20_SUBDEVICE_0 = 0x2080
|
||||||
|
|
||||||
|
# NV01_ROOT GPU queries (ctrl0000gpu.h) — resolve PCI identity to the RM
|
||||||
|
# device/subdevice instance numbers used by NV0080 and NV2080 allocations;
|
||||||
|
# neither number is a Linux device minor.
|
||||||
|
_CTRL_GPU_GET_ATTACHED_IDS = 0x201
|
||||||
|
_CTRL_GPU_GET_ID_INFO_V2 = 0x205
|
||||||
|
_CTRL_GPU_GET_PCI_INFO = 0x21B
|
||||||
|
_MAX_GPUS = 32
|
||||||
|
_INVALID_GPU_ID = 0xFFFFFFFF
|
||||||
|
|
||||||
|
# Private NV2080 power-limit client commands. Payloads compared against
|
||||||
|
# NvAPI and GSP from R595, R610 and R615 (native RM payloads, without
|
||||||
|
# NvAPI's 0x10-byte transport prefix).
|
||||||
|
_PWR_GET_INFO = 0x2080_A630
|
||||||
|
_PWR_GET_CONTROL = 0x2080_A632
|
||||||
|
_PWR_SET_CONTROL = 0x2080_E633
|
||||||
|
_ORDINARY_CLIENT = 0xFE
|
||||||
|
_LOWER_LIMIT_MW = 30_000 # experimental floor: 30 W
|
||||||
|
|
||||||
|
|
||||||
|
def _ioctl_rw(size: int, nr: int, magic: int = NV_IOCTL_MAGIC) -> int:
|
||||||
|
"""Linux ioctl request code: dir=RW, given size/type/nr."""
|
||||||
|
return (2 << 30) | (size << 16) | (magic << 8) | nr
|
||||||
|
|
||||||
|
|
||||||
|
def _ioctl_call(fd: int, code: int, arg) -> None:
|
||||||
|
"""Issue an ioctl, converting errno failures to RmPowerError.
|
||||||
|
|
||||||
|
The driver normally reports failures as an RM status in the parameter
|
||||||
|
struct, but an experimental interface can also fail at the kernel level
|
||||||
|
(ENOTTY/EBADF/EPERM across driver versions). Converting to RmPowerError
|
||||||
|
keeps the module's error contract uniform and lets callers clean up fds.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
fcntl.ioctl(fd, code, arg)
|
||||||
|
except OSError as exc:
|
||||||
|
raise RmPowerError(f"ioctl 0x{code:x} failed: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
# ── NVOS parameter structs (nvos.h) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class _NVOS21(ctypes.Structure):
|
||||||
|
_fields_ = [
|
||||||
|
("hRoot", ctypes.c_uint32),
|
||||||
|
("hObjectParent", ctypes.c_uint32),
|
||||||
|
("hObjectNew", ctypes.c_uint32),
|
||||||
|
("hClass", ctypes.c_uint32),
|
||||||
|
("pAllocParms", ctypes.c_uint64),
|
||||||
|
("paramsSize", ctypes.c_uint32),
|
||||||
|
("status", ctypes.c_uint32),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class _NVOS64(ctypes.Structure):
|
||||||
|
_fields_ = [
|
||||||
|
("hRoot", ctypes.c_uint32),
|
||||||
|
("hObjectParent", ctypes.c_uint32),
|
||||||
|
("hObjectNew", ctypes.c_uint32),
|
||||||
|
("hClass", ctypes.c_uint32),
|
||||||
|
("pAllocParms", ctypes.c_uint64),
|
||||||
|
("pRightsRequested", ctypes.c_uint64),
|
||||||
|
("paramsSize", ctypes.c_uint32),
|
||||||
|
("flags", ctypes.c_uint32),
|
||||||
|
("status", ctypes.c_uint32),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class _NVOS54(ctypes.Structure):
|
||||||
|
_fields_ = [
|
||||||
|
("hClient", ctypes.c_uint32),
|
||||||
|
("hObject", ctypes.c_uint32),
|
||||||
|
("cmd", ctypes.c_uint32),
|
||||||
|
("flags", ctypes.c_uint32),
|
||||||
|
("params", ctypes.c_uint64),
|
||||||
|
("paramsSize", ctypes.c_uint32),
|
||||||
|
("status", ctypes.c_uint32),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class _NV0080_ALLOC(ctypes.Structure):
|
||||||
|
_fields_ = [
|
||||||
|
("deviceId", ctypes.c_uint32),
|
||||||
|
("deviceFlags", ctypes.c_uint32),
|
||||||
|
("vgpuInstance", ctypes.c_uint32),
|
||||||
|
("pad", ctypes.c_uint32),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class _NV2080_ALLOC(ctypes.Structure):
|
||||||
|
_fields_ = [
|
||||||
|
("subDeviceId", ctypes.c_uint32),
|
||||||
|
("clientShare", ctypes.c_uint32),
|
||||||
|
("flags", ctypes.c_uint32),
|
||||||
|
("pad", ctypes.c_uint32),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Errors ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class RmPowerError(RuntimeError):
|
||||||
|
"""Raised when the RM power-limit interface is unavailable or fails."""
|
||||||
|
|
||||||
|
|
||||||
|
# ── Power-limit layouts and bounds ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PowerLimitLayout:
|
||||||
|
"""Byte offsets of the private power-limit payloads for one wire format."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
info_size: int
|
||||||
|
control_size: int
|
||||||
|
info_min_at: int
|
||||||
|
request_at: int
|
||||||
|
client_at: int
|
||||||
|
mask_end: int
|
||||||
|
|
||||||
|
|
||||||
|
EXTENDED_LAYOUT = PowerLimitLayout(
|
||||||
|
name="extended",
|
||||||
|
info_size=0x924,
|
||||||
|
control_size=0x328,
|
||||||
|
info_min_at=0x28,
|
||||||
|
request_at=0x2C,
|
||||||
|
client_at=0x30,
|
||||||
|
mask_end=0x24,
|
||||||
|
)
|
||||||
|
LEGACY_LAYOUT = PowerLimitLayout(
|
||||||
|
name="legacy",
|
||||||
|
info_size=0x488,
|
||||||
|
control_size=0x188,
|
||||||
|
info_min_at=0xC,
|
||||||
|
request_at=0xC,
|
||||||
|
client_at=0x10,
|
||||||
|
mask_end=0x8,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PowerLimitBounds:
|
||||||
|
"""Power limit bounds in milliwatts (NVML/RM units)."""
|
||||||
|
|
||||||
|
min_mw: int
|
||||||
|
default_mw: int
|
||||||
|
max_mw: int
|
||||||
|
|
||||||
|
def lower_min_mw(self) -> int:
|
||||||
|
"""Effective minimum when the experimental route is active."""
|
||||||
|
return min(self.min_mw, _LOWER_LIMIT_MW)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LowerPowerLimit:
|
||||||
|
"""A validated RM power-limit layout that can be written."""
|
||||||
|
|
||||||
|
bounds: PowerLimitBounds
|
||||||
|
layout: PowerLimitLayout
|
||||||
|
|
||||||
|
def lower_min_mw(self) -> int:
|
||||||
|
return self.bounds.lower_min_mw()
|
||||||
|
|
||||||
|
|
||||||
|
# ── PCI identity → RM instance resolution ────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PciLocation:
|
||||||
|
domain: int
|
||||||
|
bus: int
|
||||||
|
dev: int
|
||||||
|
func: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_gpu_instance(
|
||||||
|
pci: PciLocation,
|
||||||
|
query: Callable[[int, bytearray], None],
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
"""Resolve (device_instance, subdevice_instance) by PCI identity.
|
||||||
|
|
||||||
|
/dev/nvidiaN minors and RM device instances can have different orders;
|
||||||
|
the RM object must be matched by PCI domain/bus/slot, not by index.
|
||||||
|
The RM query exposes domain/bus/slot but no PCI function, so only
|
||||||
|
function-zero devices can be matched (never another function of a
|
||||||
|
multifunction device).
|
||||||
|
"""
|
||||||
|
if pci.func != 0:
|
||||||
|
raise RmPowerError("RM GPU lookup requires PCI function zero")
|
||||||
|
|
||||||
|
attached = bytearray(_MAX_GPUS * 4)
|
||||||
|
query(_CTRL_GPU_GET_ATTACHED_IDS, attached)
|
||||||
|
|
||||||
|
for i in range(_MAX_GPUS):
|
||||||
|
gpu_id = struct.unpack_from("<I", attached, i * 4)[0]
|
||||||
|
if gpu_id == _INVALID_GPU_ID:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# NV0000_CTRL_GPU_GET_PCI_INFO_PARAMS: u32 gpuId, u32 domain,
|
||||||
|
# u16 bus, u16 slot.
|
||||||
|
location = bytearray(12)
|
||||||
|
location[0:4] = struct.pack("<I", gpu_id)
|
||||||
|
query(_CTRL_GPU_GET_PCI_INFO, location)
|
||||||
|
domain, bus, slot = struct.unpack_from("<IHH", location, 4)
|
||||||
|
if (domain, bus, slot) != (pci.domain, pci.bus, pci.dev):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# NV0000_CTRL_GPU_GET_ID_INFO_V2_PARAMS: eight u32 fields, with
|
||||||
|
# deviceInstance/subDeviceInstance at +8/+12.
|
||||||
|
info = bytearray(32)
|
||||||
|
info[0:4] = struct.pack("<I", gpu_id)
|
||||||
|
query(_CTRL_GPU_GET_ID_INFO_V2, info)
|
||||||
|
device, subdevice = struct.unpack_from("<II", info, 8)
|
||||||
|
return device, subdevice
|
||||||
|
|
||||||
|
raise RmPowerError(f"no RM GPU matches PCI location {pci}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── RM handle ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _rm_control(fd: int, client: int, obj: int, cmd: int, buf: bytearray) -> None:
|
||||||
|
"""Issue an NVOS54 RM control whose parameter block is a byte buffer."""
|
||||||
|
arr = (ctypes.c_uint8 * len(buf)).from_buffer(buf)
|
||||||
|
req = _NVOS54(
|
||||||
|
hClient=client,
|
||||||
|
hObject=obj,
|
||||||
|
cmd=cmd,
|
||||||
|
flags=0,
|
||||||
|
params=ctypes.addressof(arr),
|
||||||
|
paramsSize=len(buf),
|
||||||
|
status=0,
|
||||||
|
)
|
||||||
|
_ioctl_call(fd, _ioctl_rw(ctypes.sizeof(_NVOS54), NV_ESC_RM_CONTROL), req)
|
||||||
|
if req.status != 0:
|
||||||
|
raise RmPowerError(
|
||||||
|
f"RM control 0x{cmd:08x} failed with status 0x{req.status:x}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _alloc_client(fd: int) -> int:
|
||||||
|
"""Allocate an RM client (NVOS21, all-zero parameters)."""
|
||||||
|
req = _NVOS21()
|
||||||
|
_ioctl_call(fd, _ioctl_rw(ctypes.sizeof(_NVOS21), NV_ESC_RM_ALLOC), req)
|
||||||
|
if req.status != 0:
|
||||||
|
raise RmPowerError(f"could not allocate RM client (status 0x{req.status:x})")
|
||||||
|
return req.hObjectNew
|
||||||
|
|
||||||
|
|
||||||
|
def _alloc_object(
|
||||||
|
fd: int, client: int, parent: int, class_id: int, alloc_params: ctypes.Structure
|
||||||
|
) -> int:
|
||||||
|
"""Allocate an RM object (NVOS64) and return its handle."""
|
||||||
|
req = _NVOS64(
|
||||||
|
hRoot=client,
|
||||||
|
hObjectParent=parent,
|
||||||
|
hObjectNew=0,
|
||||||
|
hClass=class_id,
|
||||||
|
pAllocParms=ctypes.addressof(alloc_params),
|
||||||
|
pRightsRequested=0,
|
||||||
|
paramsSize=ctypes.sizeof(alloc_params),
|
||||||
|
flags=0,
|
||||||
|
status=0,
|
||||||
|
)
|
||||||
|
_ioctl_call(fd, _ioctl_rw(ctypes.sizeof(_NVOS64), NV_ESC_RM_ALLOC), req)
|
||||||
|
if req.status != 0:
|
||||||
|
raise RmPowerError(
|
||||||
|
f"RM class 0x{class_id:x} allocation failed (status 0x{req.status:x})"
|
||||||
|
)
|
||||||
|
return req.hObjectNew
|
||||||
|
|
||||||
|
|
||||||
|
def _register_fd(device_fd: int, nvidiactl_fd: int) -> None:
|
||||||
|
"""Register the nvidiactl client with the device fd (NV_ESC_REGISTER_FD).
|
||||||
|
|
||||||
|
The ioctl is issued on the /dev/nvidiaN fd; the argument is the
|
||||||
|
nvidiactl fd to associate with it.
|
||||||
|
"""
|
||||||
|
_ioctl_call(
|
||||||
|
device_fd,
|
||||||
|
_ioctl_rw(4, NV_ESC_REGISTER_FD, NV_IOCTL_MAGIC_F),
|
||||||
|
struct.pack("i", nvidiactl_fd),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RmHandle:
|
||||||
|
"""An NVIDIA RM client with device + subdevice objects for one GPU."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
nvidiactl_fd: int,
|
||||||
|
device_fd: int,
|
||||||
|
client_handle: int,
|
||||||
|
device_handle: int,
|
||||||
|
subdevice_handle: int,
|
||||||
|
) -> None:
|
||||||
|
self._nvidiactl_fd = nvidiactl_fd
|
||||||
|
self._device_fd = device_fd
|
||||||
|
self.client_handle = client_handle
|
||||||
|
self.device_handle = device_handle
|
||||||
|
self.subdevice_handle = subdevice_handle
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def open(cls, gpu_index: int) -> RmHandle:
|
||||||
|
"""Open an RM handle for the GPU at the given NVML index.
|
||||||
|
|
||||||
|
The RM device/subdevice instances are resolved by PCI identity
|
||||||
|
(minors and RM instances can have different orders).
|
||||||
|
"""
|
||||||
|
pynvml = _ensure_nvml()
|
||||||
|
|
||||||
|
try:
|
||||||
|
handle = pynvml.nvmlDeviceGetHandleByIndex(gpu_index)
|
||||||
|
minor = int(pynvml.nvmlDeviceGetMinorNumber(handle))
|
||||||
|
pci_info = pynvml.nvmlDeviceGetPciInfo(handle)
|
||||||
|
pci = PciLocation(
|
||||||
|
domain=int(pci_info.domain),
|
||||||
|
bus=int(pci_info.bus),
|
||||||
|
dev=int(pci_info.device),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise RmPowerError(f"NVML query for GPU {gpu_index} failed: {exc}") from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
nvidiactl_fd = os.open("/dev/nvidiactl", os.O_RDWR)
|
||||||
|
except OSError as exc:
|
||||||
|
raise RmPowerError(f"could not open /dev/nvidiactl: {exc}") from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
client_handle = _alloc_client(nvidiactl_fd)
|
||||||
|
device_instance, subdevice_instance = resolve_gpu_instance(
|
||||||
|
pci,
|
||||||
|
lambda cmd, buf: _rm_control(
|
||||||
|
nvidiactl_fd, client_handle, client_handle, cmd, buf
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except RmPowerError:
|
||||||
|
os.close(nvidiactl_fd)
|
||||||
|
raise
|
||||||
|
|
||||||
|
try:
|
||||||
|
device_fd = os.open(f"/dev/nvidia{minor}", os.O_RDWR)
|
||||||
|
except OSError as exc:
|
||||||
|
os.close(nvidiactl_fd)
|
||||||
|
raise RmPowerError(f"could not open /dev/nvidia{minor}: {exc}") from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
_register_fd(device_fd, nvidiactl_fd)
|
||||||
|
device_handle = _alloc_object(
|
||||||
|
nvidiactl_fd,
|
||||||
|
client_handle,
|
||||||
|
client_handle,
|
||||||
|
NV01_DEVICE_0,
|
||||||
|
_NV0080_ALLOC(deviceId=device_instance),
|
||||||
|
)
|
||||||
|
subdevice_handle = _alloc_object(
|
||||||
|
nvidiactl_fd,
|
||||||
|
client_handle,
|
||||||
|
device_handle,
|
||||||
|
NV20_SUBDEVICE_0,
|
||||||
|
_NV2080_ALLOC(subDeviceId=subdevice_instance),
|
||||||
|
)
|
||||||
|
except RmPowerError:
|
||||||
|
os.close(device_fd)
|
||||||
|
os.close(nvidiactl_fd)
|
||||||
|
raise
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
nvidiactl_fd, device_fd, client_handle, device_handle, subdevice_handle
|
||||||
|
)
|
||||||
|
|
||||||
|
def control(self, cmd: int, buf: bytearray) -> None:
|
||||||
|
"""Issue an NVOS54 RM control on the subdevice with a byte buffer."""
|
||||||
|
_rm_control(
|
||||||
|
self._nvidiactl_fd, self.client_handle, self.subdevice_handle, cmd, buf
|
||||||
|
)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Close the fds; the driver reclaims the RM client objects."""
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
os.close(self._device_fd)
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
os.close(self._nvidiactl_fd)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Power-limit probe / set (pure logic, testable with a fake query) ─────────
|
||||||
|
|
||||||
|
|
||||||
|
def _u32(data: bytearray | bytes, offset: int) -> int:
|
||||||
|
return struct.unpack_from("<I", data, offset)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_header(layout: PowerLimitLayout, data: bytearray) -> None:
|
||||||
|
if _u32(data, 0) != 0xFF or _u32(data, 4) != 1:
|
||||||
|
raise RmPowerError("unrecognized RM power client layout")
|
||||||
|
# The extended layout has additional mask words; accepting only its low
|
||||||
|
# word would allow an unexpected client to be included in a later SET.
|
||||||
|
if any(byte != 0 for byte in data[8 : layout.mask_end]):
|
||||||
|
raise RmPowerError("unrecognized RM power client layout")
|
||||||
|
|
||||||
|
|
||||||
|
def _read_bounds(
|
||||||
|
layout: PowerLimitLayout, query: Callable[[int, bytearray], None]
|
||||||
|
) -> PowerLimitBounds:
|
||||||
|
info = bytearray(layout.info_size)
|
||||||
|
query(_PWR_GET_INFO, info)
|
||||||
|
_validate_header(layout, info)
|
||||||
|
bounds = PowerLimitBounds(
|
||||||
|
min_mw=_u32(info, layout.info_min_at),
|
||||||
|
default_mw=_u32(info, layout.info_min_at + 4),
|
||||||
|
max_mw=_u32(info, layout.info_min_at + 8),
|
||||||
|
)
|
||||||
|
if not (
|
||||||
|
bounds.min_mw > 0
|
||||||
|
and bounds.min_mw <= bounds.default_mw
|
||||||
|
and bounds.default_mw <= bounds.max_mw
|
||||||
|
):
|
||||||
|
raise RmPowerError("invalid RM power limit bounds")
|
||||||
|
return bounds
|
||||||
|
|
||||||
|
|
||||||
|
def _read_control(
|
||||||
|
layout: PowerLimitLayout, query: Callable[[int, bytearray], None]
|
||||||
|
) -> bytearray:
|
||||||
|
control = bytearray(layout.control_size)
|
||||||
|
control[4:8] = struct.pack("<I", 1)
|
||||||
|
control[layout.client_at] = _ORDINARY_CLIENT
|
||||||
|
query(_PWR_GET_CONTROL, control)
|
||||||
|
_validate_header(layout, control)
|
||||||
|
if control[layout.client_at] != _ORDINARY_CLIENT:
|
||||||
|
raise RmPowerError("unexpected power client")
|
||||||
|
if _u32(control, layout.request_at) in (0, 0xFFFFFFFF):
|
||||||
|
raise RmPowerError("no ordinary power request available")
|
||||||
|
return control
|
||||||
|
|
||||||
|
|
||||||
|
def probe(
|
||||||
|
nvml_bounds: PowerLimitBounds,
|
||||||
|
nvml_current_mw: int,
|
||||||
|
query: Callable[[int, bytearray], None],
|
||||||
|
) -> LowerPowerLimit:
|
||||||
|
"""GET-only discovery of the RM power-limit layout.
|
||||||
|
|
||||||
|
Probes the two known wire formats using GETs only. A driver version
|
||||||
|
number is not evidence that the payload still has the same layout or
|
||||||
|
units, so the bounds and the current request are validated against
|
||||||
|
NVML. Discovery never issues a SET.
|
||||||
|
"""
|
||||||
|
if sys.byteorder != "little":
|
||||||
|
raise RmPowerError("little-endian host required")
|
||||||
|
errors: list[str] = []
|
||||||
|
for layout in (EXTENDED_LAYOUT, LEGACY_LAYOUT):
|
||||||
|
try:
|
||||||
|
bounds = _read_bounds(layout, query)
|
||||||
|
if bounds != nvml_bounds:
|
||||||
|
raise RmPowerError("RM power bounds differ from NVML")
|
||||||
|
control = _read_control(layout, query)
|
||||||
|
if _u32(control, layout.request_at) != nvml_current_mw:
|
||||||
|
raise RmPowerError("RM ordinary power request differs from NVML")
|
||||||
|
return LowerPowerLimit(bounds=bounds, layout=layout)
|
||||||
|
except RmPowerError as exc:
|
||||||
|
errors.append(f"{layout.name}: {exc}")
|
||||||
|
raise RmPowerError("no compatible RM power layout: " + "; ".join(errors))
|
||||||
|
|
||||||
|
|
||||||
|
def set_limit(
|
||||||
|
limit_mw: int,
|
||||||
|
support: LowerPowerLimit,
|
||||||
|
query: Callable[[int, bytearray], None],
|
||||||
|
) -> None:
|
||||||
|
"""Set the ordinary-client power request with readback verification.
|
||||||
|
|
||||||
|
Keeps the entire current payload, changing only entry 0's request.
|
||||||
|
Mask 1 and selector 0xFE prevent modifying any other entry or the
|
||||||
|
additional F8 client. A failed SET can have side effects, so the
|
||||||
|
previous request is restored even on transport failure — and the
|
||||||
|
restore uses 0xFE so a previous limit below the VBIOS minimum can
|
||||||
|
also be restored.
|
||||||
|
"""
|
||||||
|
layout = support.layout
|
||||||
|
bounds = _read_bounds(layout, query)
|
||||||
|
if bounds != support.bounds:
|
||||||
|
raise RmPowerError("RM power bounds changed since discovery")
|
||||||
|
lower = bounds.lower_min_mw()
|
||||||
|
if not (lower <= limit_mw <= bounds.max_mw):
|
||||||
|
raise RmPowerError(
|
||||||
|
f"power limit {limit_mw} mW outside supported range "
|
||||||
|
f"{lower}..{bounds.max_mw} mW"
|
||||||
|
)
|
||||||
|
|
||||||
|
before = _read_control(layout, query)
|
||||||
|
if _u32(before, layout.request_at) == limit_mw:
|
||||||
|
return
|
||||||
|
|
||||||
|
expected = bytearray(before)
|
||||||
|
expected[layout.request_at : layout.request_at + 4] = struct.pack("<I", limit_mw)
|
||||||
|
|
||||||
|
try:
|
||||||
|
request = bytearray(expected)
|
||||||
|
query(_PWR_SET_CONTROL, request)
|
||||||
|
if _read_control(layout, query) != expected:
|
||||||
|
raise RmPowerError("power request readback differs")
|
||||||
|
except RmPowerError as apply_error:
|
||||||
|
try:
|
||||||
|
restore = bytearray(before)
|
||||||
|
query(_PWR_SET_CONTROL, restore)
|
||||||
|
if _read_control(layout, query) != before:
|
||||||
|
raise RmPowerError("restored power request differs")
|
||||||
|
except RmPowerError as restore_error:
|
||||||
|
raise RmPowerError(
|
||||||
|
f"power request failed: {apply_error}; "
|
||||||
|
f"restoration also failed: {restore_error}"
|
||||||
|
) from None
|
||||||
|
raise RmPowerError(f"{apply_error} (previous power request restored)") from None
|
||||||
|
|
||||||
|
|
||||||
|
# ── High-level API (wires NVML state + RmHandle to the pure logic) ───────────
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_nvml():
|
||||||
|
"""Return pynvml with NVML initialized (nvmlInit is refcounted)."""
|
||||||
|
import pynvml
|
||||||
|
|
||||||
|
pynvml.nvmlInit()
|
||||||
|
return pynvml
|
||||||
|
|
||||||
|
|
||||||
|
def _nvml_power_state(gpu_index: int) -> tuple[PowerLimitBounds, int]:
|
||||||
|
"""Return (bounds, current_mw) from NVML for the given GPU."""
|
||||||
|
pynvml = _ensure_nvml()
|
||||||
|
|
||||||
|
try:
|
||||||
|
handle = pynvml.nvmlDeviceGetHandleByIndex(gpu_index)
|
||||||
|
min_mw, max_mw = pynvml.nvmlDeviceGetPowerManagementLimitConstraints(handle)
|
||||||
|
default_mw = pynvml.nvmlDeviceGetPowerManagementDefaultLimit(handle)
|
||||||
|
current_mw = pynvml.nvmlDeviceGetPowerManagementLimit(handle)
|
||||||
|
bounds = PowerLimitBounds(int(min_mw), int(default_mw), int(max_mw))
|
||||||
|
current = int(current_mw)
|
||||||
|
except Exception as exc:
|
||||||
|
raise RmPowerError(
|
||||||
|
f"NVML power state for GPU {gpu_index} unavailable: {exc}"
|
||||||
|
) from exc
|
||||||
|
return bounds, current
|
||||||
|
|
||||||
|
|
||||||
|
def probe_gpu(gpu_index: int = 0) -> PowerLimitBounds | None:
|
||||||
|
"""GET-only discovery of the RM power-limit interface for a GPU.
|
||||||
|
|
||||||
|
Returns the validated power bounds (milliwatts) when a compatible RM
|
||||||
|
layout is present, else None. Never issues a write.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
bounds, current = _nvml_power_state(gpu_index)
|
||||||
|
except Exception as exc:
|
||||||
|
log.debug("RM probe: NVML state unavailable: %s", exc)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
handle = RmHandle.open(gpu_index)
|
||||||
|
except RmPowerError as exc:
|
||||||
|
log.debug("RM probe: handle open failed: %s", exc)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
probe(bounds, current, handle.control)
|
||||||
|
return bounds
|
||||||
|
except RmPowerError as exc:
|
||||||
|
log.debug("RM probe: %s", exc)
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
handle.close()
|
||||||
|
|
||||||
|
|
||||||
|
def set_power_limit_w(gpu_index: int, limit_w: int) -> None:
|
||||||
|
"""Set the board power limit (watts) via the RM interface.
|
||||||
|
|
||||||
|
Raises RmPowerError on any failure (probe, range, write, readback).
|
||||||
|
A failed write restores the previous request.
|
||||||
|
"""
|
||||||
|
bounds, current = _nvml_power_state(gpu_index)
|
||||||
|
handle = RmHandle.open(gpu_index)
|
||||||
|
try:
|
||||||
|
support = probe(bounds, current, handle.control)
|
||||||
|
set_limit(int(limit_w) * 1000, support, handle.control)
|
||||||
|
finally:
|
||||||
|
handle.close()
|
||||||
@@ -121,6 +121,15 @@ def restore(gpu, snapshot_dir: str, filepath: str | None = None) -> bool:
|
|||||||
print(f"Snapshot file not found: {filepath}")
|
print(f"Snapshot file not found: {filepath}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# Contain the path inside the snapshot directory — callers (in
|
||||||
|
# particular the HTTP API) must not be able to point the restore at
|
||||||
|
# arbitrary files on the filesystem.
|
||||||
|
snap_dir = os.path.realpath(snapshot_dir)
|
||||||
|
resolved = os.path.realpath(filepath)
|
||||||
|
if not resolved.startswith(snap_dir + os.sep):
|
||||||
|
print(f"Snapshot path outside snapshot directory: {filepath}")
|
||||||
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(filepath, "rb") as f:
|
with open(filepath, "rb") as f:
|
||||||
raw = f.read()
|
raw = f.read()
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from .errors import NVAPI_ERRORS
|
|||||||
|
|
||||||
def load_nvapi() -> ctypes.CDLL:
|
def load_nvapi() -> ctypes.CDLL:
|
||||||
"""Load libnvidia-api.so from the NVIDIA driver."""
|
"""Load libnvidia-api.so from the NVIDIA driver."""
|
||||||
for name in ("libnvidia-api.so", "libnvidia-api.so.1"):
|
for name in ("libnvidia-api.so", "libnvidia-api.so.1"): # gitleaks:allow
|
||||||
try:
|
try:
|
||||||
return ctypes.CDLL(name)
|
return ctypes.CDLL(name)
|
||||||
except OSError:
|
except OSError:
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ def apply_profile(gpu_index: int, name: str, cfg) -> list[str]:
|
|||||||
errs.append(f"Mem offset: {msg}")
|
errs.append(f"Mem offset: {msg}")
|
||||||
|
|
||||||
if profile.power_limit_w is not None:
|
if profile.power_limit_w is not None:
|
||||||
ok, msg = set_power_limit(profile.power_limit_w, gpu_index)
|
mode = profile.power_cap_mode or "nvml"
|
||||||
|
ok, msg = set_power_limit(profile.power_limit_w, gpu_index, mode)
|
||||||
if not ok:
|
if not ok:
|
||||||
errs.append(f"Power limit: {msg}")
|
errs.append(f"Power limit: {msg}")
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ class ProfileData:
|
|||||||
curve_deltas: dict[str, int] # { "index": delta_khz }
|
curve_deltas: dict[str, int] # { "index": delta_khz }
|
||||||
mem_offset_mhz: int | None = None
|
mem_offset_mhz: int | None = None
|
||||||
power_limit_w: int | None = None
|
power_limit_w: int | None = None
|
||||||
|
# How power_limit_w is applied: "nvml" (default) or "ioctl" (experimental
|
||||||
|
# RM power control, permits values below the VBIOS minimum).
|
||||||
|
power_cap_mode: str | None = None
|
||||||
fan_curve: list[dict[str, int]] | None = None
|
fan_curve: list[dict[str, int]] | None = None
|
||||||
# Fan indices controlled by fan_curve (0-based); None = all fans.
|
# Fan indices controlled by fan_curve (0-based); None = all fans.
|
||||||
fan_targets: list[int] | None = None
|
fan_targets: list[int] | None = None
|
||||||
@@ -55,6 +58,9 @@ def load_profile(filepath: str) -> ProfileData:
|
|||||||
# Drop removed fields so old profiles don't cause TypeError.
|
# Drop removed fields so old profiles don't cause TypeError.
|
||||||
for obsolete in ("gpu_locked_min_mhz", "gpu_locked_max_mhz", "vram_p0_offset_mhz"):
|
for obsolete in ("gpu_locked_min_mhz", "gpu_locked_max_mhz", "vram_p0_offset_mhz"):
|
||||||
data.pop(obsolete, None)
|
data.pop(obsolete, None)
|
||||||
|
# Normalize the experimental power-cap mode; unknown values fall back to NVML.
|
||||||
|
if data.get("power_cap_mode") not in (None, "nvml", "ioctl"):
|
||||||
|
data["power_cap_mode"] = None
|
||||||
return ProfileData(**data)
|
return ProfileData(**data)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+162
-24
@@ -11,7 +11,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
from contextlib import asynccontextmanager, suppress
|
from contextlib import asynccontextmanager, suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, Protocol
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
@@ -20,7 +20,7 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from . import auth
|
from . import auth
|
||||||
from .config import Config, default_config
|
from .config import Config, default_config, tls_enabled
|
||||||
from .hal.dashboard import get_dashboard_info
|
from .hal.dashboard import get_dashboard_info
|
||||||
from .hal.fans import (
|
from .hal.fans import (
|
||||||
get_fan_info,
|
get_fan_info,
|
||||||
@@ -552,12 +552,10 @@ app.add_middleware(AuthMiddleware)
|
|||||||
|
|
||||||
class WriteRequest(BaseModel):
|
class WriteRequest(BaseModel):
|
||||||
deltas: dict[int, int] # {point_index: delta_kHz}
|
deltas: dict[int, int] # {point_index: delta_kHz}
|
||||||
max_delta_khz: int | None = None # per-request safety limit override
|
|
||||||
|
|
||||||
|
|
||||||
class GlobalOffsetRequest(BaseModel):
|
class GlobalOffsetRequest(BaseModel):
|
||||||
delta_khz: int
|
delta_khz: int
|
||||||
max_delta_khz: int | None = None # per-request safety limit override
|
|
||||||
|
|
||||||
|
|
||||||
class VerifyRequest(BaseModel):
|
class VerifyRequest(BaseModel):
|
||||||
@@ -571,6 +569,8 @@ class SnapshotRestoreRequest(BaseModel):
|
|||||||
class LimitsRequest(BaseModel):
|
class LimitsRequest(BaseModel):
|
||||||
power_limit_w: int | None = None
|
power_limit_w: int | None = None
|
||||||
mem_offset_mhz: int | None = None
|
mem_offset_mhz: int | None = None
|
||||||
|
# "nvml" (default) or "ioctl" (experimental RM power control).
|
||||||
|
power_cap_mode: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ProfileSaveRequest(BaseModel):
|
class ProfileSaveRequest(BaseModel):
|
||||||
@@ -654,7 +654,7 @@ async def api_auth_login(req: LoginRequest, request: Request):
|
|||||||
if not users:
|
if not users:
|
||||||
raise HTTPException(status_code=404, detail="Authentication is not enabled")
|
raise HTTPException(status_code=404, detail="Authentication is not enabled")
|
||||||
|
|
||||||
client_ip = request.client.host if request.client else "unknown"
|
client_ip = _client_ip(request, cfg.trusted_proxies)
|
||||||
if auth.is_locked_out(client_ip):
|
if auth.is_locked_out(client_ip):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=429, detail="Too many failed attempts. Try again later."
|
status_code=429, detail="Too many failed attempts. Try again later."
|
||||||
@@ -677,6 +677,7 @@ async def api_auth_login(req: LoginRequest, request: Request):
|
|||||||
max_age=auth.SESSION_TTL_S,
|
max_age=auth.SESSION_TTL_S,
|
||||||
httponly=True,
|
httponly=True,
|
||||||
samesite="lax",
|
samesite="lax",
|
||||||
|
secure=tls_enabled(cfg),
|
||||||
path="/",
|
path="/",
|
||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
@@ -710,6 +711,36 @@ def _require_gpu(gpu_index: int = 0):
|
|||||||
return gpu, g_state
|
return gpu, g_state
|
||||||
|
|
||||||
|
|
||||||
|
class _ClientIpSource(Protocol):
|
||||||
|
"""Structural type for the request objects _client_ip accepts.
|
||||||
|
|
||||||
|
Both Starlette's Request and WebSocket expose these; tests may pass
|
||||||
|
lightweight duck types.
|
||||||
|
"""
|
||||||
|
|
||||||
|
client: Any
|
||||||
|
headers: Any
|
||||||
|
|
||||||
|
|
||||||
|
def _client_ip(request: _ClientIpSource, trusted_proxies: list[str]) -> str:
|
||||||
|
"""Best-effort client IP for the login lockout.
|
||||||
|
|
||||||
|
When the direct peer is a configured trusted proxy (e.g. a TLS
|
||||||
|
reverse proxy), use the rightmost X-Forwarded-For entry that is not
|
||||||
|
itself a trusted proxy. Otherwise use the direct peer address —
|
||||||
|
X-Forwarded-For is spoofable, so it is only honoured for peers the
|
||||||
|
operator explicitly listed in ``trusted_proxies``.
|
||||||
|
"""
|
||||||
|
peer = request.client.host if request.client else "unknown"
|
||||||
|
if not trusted_proxies or peer not in trusted_proxies:
|
||||||
|
return peer
|
||||||
|
hops = [h.strip() for h in request.headers.get("x-forwarded-for", "").split(",")]
|
||||||
|
for hop in reversed(hops):
|
||||||
|
if hop and hop not in trusted_proxies:
|
||||||
|
return hop
|
||||||
|
return peer
|
||||||
|
|
||||||
|
|
||||||
# ── REST endpoints ────────────────────────────────────────────────────────────
|
# ── REST endpoints ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -885,6 +916,17 @@ def _persist_fan_curves(fan_curves: dict) -> None:
|
|||||||
_persist_config_field("fan_curves", fan_curves if fan_curves else None)
|
_persist_config_field("fan_curves", fan_curves if fan_curves else None)
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_power_cap_modes(modes: dict[str, str]) -> None:
|
||||||
|
"""Persist the per-GPU experimental power-cap mode dict to config.json."""
|
||||||
|
_persist_config_field("power_cap_modes", modes if modes else None)
|
||||||
|
|
||||||
|
|
||||||
|
def _power_cap_mode(cfg: Config, gpu_index: int) -> str:
|
||||||
|
"""Return the effective power-cap mode for a GPU ("nvml" or "ioctl")."""
|
||||||
|
mode = cfg.power_cap_modes.get(_gpu_stable_key(gpu_index), "nvml")
|
||||||
|
return mode if mode in ("nvml", "ioctl") else "nvml"
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/profiles")
|
@app.get("/api/profiles")
|
||||||
async def api_profiles(gpu_index: int = 0):
|
async def api_profiles(gpu_index: int = 0):
|
||||||
"""List saved native profiles, the active profile name, and the auto-load profile name."""
|
"""List saved native profiles, the active profile name, and the auto-load profile name."""
|
||||||
@@ -912,13 +954,15 @@ async def api_profile_save(req: ProfileSaveRequest, gpu_index: int = 0):
|
|||||||
curve_deltas = {str(p.index): p.delta_khz for p in state.points if p.delta_khz != 0}
|
curve_deltas = {str(p.index): p.delta_khz for p in state.points if p.delta_khz != 0}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
power_info = await _run(get_power_limit, gpu_index)
|
mode = _power_cap_mode(cfg, gpu_index)
|
||||||
|
power_info = await _run(get_power_limit, gpu_index, mode)
|
||||||
offsets = await _run(get_clock_offsets, gpu_index)
|
offsets = await _run(get_clock_offsets, gpu_index)
|
||||||
power_limit_w = power_info.get("power_limit_w")
|
power_limit_w = power_info.get("power_limit_w")
|
||||||
mem_offset_mhz = offsets.get("mem_offset_mhz")
|
mem_offset_mhz = offsets.get("mem_offset_mhz")
|
||||||
except Exception:
|
except Exception:
|
||||||
power_limit_w = None
|
power_limit_w = None
|
||||||
mem_offset_mhz = None
|
mem_offset_mhz = None
|
||||||
|
mode = "nvml"
|
||||||
|
|
||||||
data = ProfileData(
|
data = ProfileData(
|
||||||
name=req.name,
|
name=req.name,
|
||||||
@@ -926,6 +970,7 @@ async def api_profile_save(req: ProfileSaveRequest, gpu_index: int = 0):
|
|||||||
curve_deltas=curve_deltas,
|
curve_deltas=curve_deltas,
|
||||||
mem_offset_mhz=mem_offset_mhz,
|
mem_offset_mhz=mem_offset_mhz,
|
||||||
power_limit_w=power_limit_w,
|
power_limit_w=power_limit_w,
|
||||||
|
power_cap_mode=mode,
|
||||||
fan_curve=g_state.get("fan_curve") if g_state.get("fan_curve_active") else None,
|
fan_curve=g_state.get("fan_curve") if g_state.get("fan_curve_active") else None,
|
||||||
fan_targets=g_state.get("fan_targets")
|
fan_targets=g_state.get("fan_targets")
|
||||||
if g_state.get("fan_curve_active")
|
if g_state.get("fan_curve_active")
|
||||||
@@ -1046,7 +1091,8 @@ async def _apply_profile(name: str, gpu_index: int = 0) -> list[str]:
|
|||||||
errs.append(f"Mem offset: {msg}")
|
errs.append(f"Mem offset: {msg}")
|
||||||
|
|
||||||
if profile.power_limit_w is not None:
|
if profile.power_limit_w is not None:
|
||||||
ok, msg = await _run(set_power_limit, profile.power_limit_w, gpu_index)
|
mode = profile.power_cap_mode or "nvml"
|
||||||
|
ok, msg = await _run(set_power_limit, profile.power_limit_w, gpu_index, mode)
|
||||||
if not ok:
|
if not ok:
|
||||||
errs.append(f"Power limit: {msg}")
|
errs.append(f"Power limit: {msg}")
|
||||||
|
|
||||||
@@ -1175,7 +1221,9 @@ async def api_config_update(req: ConfigUpdateRequest):
|
|||||||
@app.get("/api/limits")
|
@app.get("/api/limits")
|
||||||
async def api_limits(gpu_index: int = 0):
|
async def api_limits(gpu_index: int = 0):
|
||||||
"""Current performance limits: power and clock offsets."""
|
"""Current performance limits: power and clock offsets."""
|
||||||
power = await _run(get_power_limit, gpu_index)
|
cfg: Config = _state["config"]
|
||||||
|
mode = _power_cap_mode(cfg, gpu_index)
|
||||||
|
power = await _run(get_power_limit, gpu_index, mode)
|
||||||
offsets = await _run(get_clock_offsets, gpu_index)
|
offsets = await _run(get_clock_offsets, gpu_index)
|
||||||
mem_off_range = await _run(get_mem_offset_range, gpu_index)
|
mem_off_range = await _run(get_mem_offset_range, gpu_index)
|
||||||
return {
|
return {
|
||||||
@@ -1189,10 +1237,36 @@ async def api_limits(gpu_index: int = 0):
|
|||||||
async def api_limits_update(req: LimitsRequest, gpu_index: int = 0):
|
async def api_limits_update(req: LimitsRequest, gpu_index: int = 0):
|
||||||
"""Update performance limits."""
|
"""Update performance limits."""
|
||||||
g_state = _get_gpu_state(gpu_index)
|
g_state = _get_gpu_state(gpu_index)
|
||||||
|
cfg: Config = _state["config"]
|
||||||
errs = []
|
errs = []
|
||||||
|
|
||||||
|
if req.power_cap_mode is not None:
|
||||||
|
if req.power_cap_mode not in ("nvml", "ioctl"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="power_cap_mode must be 'nvml' or 'ioctl'"
|
||||||
|
)
|
||||||
|
if req.power_cap_mode == "ioctl":
|
||||||
|
# Verify the GPU actually exposes the RM interface before enabling,
|
||||||
|
# so a client can't lock a GPU into a mode where every power
|
||||||
|
# operation fails (ioctl mode has no NVML fallback by design).
|
||||||
|
info = await _run(get_power_limit, gpu_index, "ioctl")
|
||||||
|
if not info.get("rm_power_supported"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="Experimental RM power control is not supported "
|
||||||
|
"on this GPU/driver",
|
||||||
|
)
|
||||||
|
key = _gpu_stable_key(gpu_index)
|
||||||
|
if req.power_cap_mode == "nvml":
|
||||||
|
cfg.power_cap_modes.pop(key, None)
|
||||||
|
else:
|
||||||
|
cfg.power_cap_modes[key] = "ioctl"
|
||||||
|
_persist_power_cap_modes(cfg.power_cap_modes)
|
||||||
|
|
||||||
|
mode = _power_cap_mode(cfg, gpu_index)
|
||||||
|
|
||||||
if req.power_limit_w is not None:
|
if req.power_limit_w is not None:
|
||||||
ok, msg = await _run(set_power_limit, req.power_limit_w, gpu_index)
|
ok, msg = await _run(set_power_limit, req.power_limit_w, gpu_index, mode)
|
||||||
if not ok:
|
if not ok:
|
||||||
errs.append(f"Power Limit: {msg}")
|
errs.append(f"Power Limit: {msg}")
|
||||||
|
|
||||||
@@ -1255,12 +1329,17 @@ async def _update_offsets_and_broadcast(gpu_index: int) -> None:
|
|||||||
async def api_limits_reset(gpu_index: int = 0):
|
async def api_limits_reset(gpu_index: int = 0):
|
||||||
"""Reset power limit to hardware default and memory clock offset to 0."""
|
"""Reset power limit to hardware default and memory clock offset to 0."""
|
||||||
g_state = _get_gpu_state(gpu_index)
|
g_state = _get_gpu_state(gpu_index)
|
||||||
|
cfg: Config = _state["config"]
|
||||||
errs = []
|
errs = []
|
||||||
|
|
||||||
power = await _run(get_power_limit, gpu_index)
|
# Reset uses the GPU's current mode: in ioctl mode the default is
|
||||||
|
# restored through the RM route (which can also restore a previous
|
||||||
|
# below-VBIOS-minimum cap).
|
||||||
|
mode = _power_cap_mode(cfg, gpu_index)
|
||||||
|
power = await _run(get_power_limit, gpu_index, mode)
|
||||||
default_w = power.get("default_power_limit_w")
|
default_w = power.get("default_power_limit_w")
|
||||||
if default_w is not None:
|
if default_w is not None:
|
||||||
ok, msg = await _run(set_power_limit, default_w, gpu_index)
|
ok, msg = await _run(set_power_limit, default_w, gpu_index, mode)
|
||||||
if not ok:
|
if not ok:
|
||||||
errs.append(f"Power Limit: {msg}")
|
errs.append(f"Power Limit: {msg}")
|
||||||
|
|
||||||
@@ -1407,10 +1486,10 @@ async def api_curve_write(req: WriteRequest, gpu_index: int = 0):
|
|||||||
|
|
||||||
vfp_state, _ = await _run(read_curve, gpu, g_state["gpu_name"])
|
vfp_state, _ = await _run(read_curve, gpu, g_state["gpu_name"])
|
||||||
|
|
||||||
effective_limit = (
|
# The safety cap is always the server-side config value — clients cannot
|
||||||
req.max_delta_khz if req.max_delta_khz is not None else cfg.max_delta_khz
|
# raise it per request (shared systems must not let one user override the
|
||||||
)
|
# hardware safety limit). Raise it in /etc/nvcurve/config.json if needed.
|
||||||
errors = validate_write(req.deltas, effective_limit)
|
errors = validate_write(req.deltas, cfg.max_delta_khz)
|
||||||
if errors:
|
if errors:
|
||||||
raise HTTPException(status_code=400, detail={"errors": errors})
|
raise HTTPException(status_code=400, detail={"errors": errors})
|
||||||
|
|
||||||
@@ -1461,10 +1540,8 @@ async def api_curve_write_global(req: GlobalOffsetRequest, gpu_index: int = 0):
|
|||||||
raise HTTPException(status_code=500, detail="Failed to read curve")
|
raise HTTPException(status_code=500, detail="Failed to read curve")
|
||||||
|
|
||||||
all_deltas = {p.index: req.delta_khz for p in vfp_state.points if p.domain == "gpu"}
|
all_deltas = {p.index: req.delta_khz for p in vfp_state.points if p.domain == "gpu"}
|
||||||
effective_limit = (
|
# Safety cap is the server-side config value only (see api_curve_write).
|
||||||
req.max_delta_khz if req.max_delta_khz is not None else cfg.max_delta_khz
|
errors = validate_write(all_deltas, cfg.max_delta_khz)
|
||||||
)
|
|
||||||
errors = validate_write(all_deltas, effective_limit)
|
|
||||||
if errors:
|
if errors:
|
||||||
raise HTTPException(status_code=400, detail={"errors": errors})
|
raise HTTPException(status_code=400, detail={"errors": errors})
|
||||||
|
|
||||||
@@ -1605,10 +1682,21 @@ async def api_curve_verify(req: VerifyRequest, gpu_index: int = 0):
|
|||||||
|
|
||||||
@app.post("/api/shutdown")
|
@app.post("/api/shutdown")
|
||||||
async def api_shutdown():
|
async def api_shutdown():
|
||||||
"""Gracefully shut down the server process."""
|
"""Gracefully shut down the server process.
|
||||||
|
|
||||||
|
Disabled when ``allow_api_shutdown`` is false in the config — on shared
|
||||||
|
systems stop the service via systemd instead.
|
||||||
|
"""
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
|
|
||||||
|
cfg: Config = _state["config"]
|
||||||
|
if not cfg.allow_api_shutdown:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="API shutdown is disabled (allow_api_shutdown: false)",
|
||||||
|
)
|
||||||
|
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
loop.call_later(0.1, lambda: os.kill(os.getpid(), signal.SIGTERM))
|
loop.call_later(0.1, lambda: os.kill(os.getpid(), signal.SIGTERM))
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
@@ -1795,7 +1883,15 @@ async def serve_spa(catchall: str):
|
|||||||
if not os.path.isdir(_dist_dir):
|
if not os.path.isdir(_dist_dir):
|
||||||
return {"error": "Frontend not built. Run pnpm build in frontend/."}
|
return {"error": "Frontend not built. Run pnpm build in frontend/."}
|
||||||
|
|
||||||
path = os.path.join(_dist_dir, catchall)
|
# Contain the resolved path inside the dist directory. The raw URL path
|
||||||
|
# can carry encoded ".." segments (e.g. /%2e%2e/etc/passwd) that would
|
||||||
|
# otherwise escape the dist dir via os.path.join — an unauthenticated
|
||||||
|
# arbitrary-file-read since the server runs as root.
|
||||||
|
base = os.path.realpath(_dist_dir)
|
||||||
|
path = os.path.realpath(os.path.join(_dist_dir, catchall))
|
||||||
|
if path != base and not path.startswith(base + os.sep):
|
||||||
|
raise HTTPException(status_code=404, detail="Not Found")
|
||||||
|
|
||||||
if os.path.isfile(path) and catchall:
|
if os.path.isfile(path) and catchall:
|
||||||
return FileResponse(path)
|
return FileResponse(path)
|
||||||
|
|
||||||
@@ -1821,8 +1917,14 @@ def run(
|
|||||||
gpu_index: int = 0,
|
gpu_index: int = 0,
|
||||||
config: Config = default_config,
|
config: Config = default_config,
|
||||||
open_browser: bool = False,
|
open_browser: bool = False,
|
||||||
|
ssl_certfile: str | None = None,
|
||||||
|
ssl_keyfile: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Start the uvicorn server. Blocking."""
|
"""Start the uvicorn server. Blocking.
|
||||||
|
|
||||||
|
When both ssl_certfile and ssl_keyfile are given (either here or in the
|
||||||
|
config), the server serves HTTPS and the session cookie is Secure.
|
||||||
|
"""
|
||||||
import socket
|
import socket
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
@@ -1830,6 +1932,34 @@ def run(
|
|||||||
|
|
||||||
_state["config"] = config
|
_state["config"] = config
|
||||||
|
|
||||||
|
# CLI flags take precedence over config values.
|
||||||
|
certfile = ssl_certfile or config.ssl_certfile
|
||||||
|
keyfile = ssl_keyfile or config.ssl_keyfile
|
||||||
|
if certfile:
|
||||||
|
config.ssl_certfile = certfile
|
||||||
|
if keyfile:
|
||||||
|
config.ssl_keyfile = keyfile
|
||||||
|
tls = bool(certfile and keyfile)
|
||||||
|
|
||||||
|
# Fail fast on a bad TLS configuration — otherwise uvicorn dies at
|
||||||
|
# startup and (in daemon mode) the error is only visible in the server
|
||||||
|
# log while `serve status` reports "not running".
|
||||||
|
tls = False
|
||||||
|
if certfile and keyfile:
|
||||||
|
missing = [
|
||||||
|
f"{label} ({path})"
|
||||||
|
for label, path in (("certificate", certfile), ("key", keyfile))
|
||||||
|
if not os.path.isfile(path)
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
print(f"Error: TLS file(s) not found: {', '.join(missing)}")
|
||||||
|
print(
|
||||||
|
"Fix the path (nvcurve service configure --ssl-certfile/--ssl-keyfile) "
|
||||||
|
"or disable TLS (--no-ssl)."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
tls = True
|
||||||
|
|
||||||
# Suppress noisy websockets keepalive ping-timeout tracebacks — these are
|
# Suppress noisy websockets keepalive ping-timeout tracebacks — these are
|
||||||
# normal disconnection events (browser tab closed, network hiccup) and
|
# normal disconnection events (browser tab closed, network hiccup) and
|
||||||
# logging them at ERROR level creates false alarm noise.
|
# logging them at ERROR level creates false alarm noise.
|
||||||
@@ -1847,7 +1977,7 @@ def run(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
url = f"http://{host}:{port}"
|
url = f"{'https' if tls else 'http'}://{host}:{port}"
|
||||||
|
|
||||||
# Print banner *before* uvicorn starts so it appears above uvicorn's own output.
|
# Print banner *before* uvicorn starts so it appears above uvicorn's own output.
|
||||||
# GPU name is populated by the lifespan; we omit it here since the server
|
# GPU name is populated by the lifespan; we omit it here since the server
|
||||||
@@ -1862,4 +1992,12 @@ def run(
|
|||||||
if open_browser and not _DEV_PORT:
|
if open_browser and not _DEV_PORT:
|
||||||
threading.Timer(1.2, lambda: _open_browser_as_user(url)).start()
|
threading.Timer(1.2, lambda: _open_browser_as_user(url)).start()
|
||||||
|
|
||||||
uvicorn.run(app, host=host, port=port, log_level="warning", access_log=False)
|
uvicorn.run(
|
||||||
|
app,
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
log_level="warning",
|
||||||
|
access_log=False,
|
||||||
|
ssl_certfile=certfile if tls else None,
|
||||||
|
ssl_keyfile=keyfile if tls else None,
|
||||||
|
)
|
||||||
@@ -19,9 +19,20 @@ dependencies = [
|
|||||||
[project.scripts]
|
[project.scripts]
|
||||||
nvcurve = "nvcurve.cli:main"
|
nvcurve = "nvcurve.cli:main"
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"hatchling", # enables local `hatch build` and resolves hatch_build.py imports
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.hatch.build.hooks.custom]
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["nvcurve"]
|
packages = ["nvcurve"]
|
||||||
|
|
||||||
|
# The custom build hook (hatch_build.py) compiles the React frontend when
|
||||||
|
# frontend/dist is missing or stale, so `uv tool install git+<repo-url>` works
|
||||||
|
# as a single command. It runs for both wheel and sdist builds.
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel.force-include]
|
[tool.hatch.build.targets.wheel.force-include]
|
||||||
"frontend/dist" = "nvcurve/frontend/dist"
|
"frontend/dist" = "nvcurve/frontend/dist"
|
||||||
|
|
||||||
|
|||||||
+267
-166
@@ -55,20 +55,20 @@ Key findings:
|
|||||||
See NvAPI_VF_Curve_Documentation.md for full technical details.
|
See NvAPI_VF_Curve_Documentation.md for full technical details.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
import ctypes
|
import ctypes
|
||||||
import struct
|
|
||||||
import sys
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
import argparse
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, List, Tuple, Set, Dict
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# NvAPI bootstrap
|
# NvAPI bootstrap
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
def load_nvapi():
|
def load_nvapi():
|
||||||
"""Load libnvidia-api.so from the NVIDIA driver."""
|
"""Load libnvidia-api.so from the NVIDIA driver."""
|
||||||
for name in ("libnvidia-api.so", "libnvidia-api.so.1"):
|
for name in ("libnvidia-api.so", "libnvidia-api.so.1"):
|
||||||
@@ -145,23 +145,20 @@ def nvcall_raw(fid: int, gpu, buf: ctypes.Array):
|
|||||||
|
|
||||||
FUNC = {
|
FUNC = {
|
||||||
# Bootstrap
|
# Bootstrap
|
||||||
"Initialize": 0x0150E828,
|
"Initialize": 0x0150E828,
|
||||||
"EnumPhysicalGPUs": 0xE5AC921F,
|
"EnumPhysicalGPUs": 0xE5AC921F,
|
||||||
"GetFullName": 0xCEEE8E9F,
|
"GetFullName": 0xCEEE8E9F,
|
||||||
|
|
||||||
# V/F curve (read)
|
# V/F curve (read)
|
||||||
"GetVFPCurve": 0x21537AD4, # ClkVfPointsGetStatus
|
"GetVFPCurve": 0x21537AD4, # ClkVfPointsGetStatus
|
||||||
"GetClockBoostMask": 0x507B4B59, # ClkVfPointsGetInfo
|
"GetClockBoostMask": 0x507B4B59, # ClkVfPointsGetInfo
|
||||||
"GetClockBoostTable": 0x23F1B133, # ClkVfPointsGetControl
|
"GetClockBoostTable": 0x23F1B133, # ClkVfPointsGetControl
|
||||||
"GetCurrentVoltage": 0x465F9BCF, # ClientVoltRailsGetStatus
|
"GetCurrentVoltage": 0x465F9BCF, # ClientVoltRailsGetStatus
|
||||||
"GetClockBoostRanges": 0x64B43A6A, # ClkDomainsGetInfo
|
"GetClockBoostRanges": 0x64B43A6A, # ClkDomainsGetInfo
|
||||||
|
|
||||||
# Additional read
|
# Additional read
|
||||||
"GetPerfLimits": 0xE440B867, # PerfClientLimitsGetStatus
|
"GetPerfLimits": 0xE440B867, # PerfClientLimitsGetStatus
|
||||||
"GetVoltBoostPercent": 0x9DF23CA1, # ClientVoltRailsGetControl
|
"GetVoltBoostPercent": 0x9DF23CA1, # ClientVoltRailsGetControl
|
||||||
|
|
||||||
# Write
|
# Write
|
||||||
"SetClockBoostTable": 0x0733E009, # ClkVfPointsSetControl
|
"SetClockBoostTable": 0x0733E009, # ClkVfPointsSetControl
|
||||||
}
|
}
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
@@ -170,33 +167,33 @@ FUNC = {
|
|||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
# GetVFPCurve (0x21537AD4)
|
# GetVFPCurve (0x21537AD4)
|
||||||
VFP_SIZE = 0x1C28
|
VFP_SIZE = 0x1C28
|
||||||
VFP_BASE = 0x48
|
VFP_BASE = 0x48
|
||||||
VFP_STRIDE = 0x1C # 28 bytes
|
VFP_STRIDE = 0x1C # 28 bytes
|
||||||
VFP_MAX_ENTRIES = (VFP_SIZE - VFP_BASE) // VFP_STRIDE # 255
|
VFP_MAX_ENTRIES = (VFP_SIZE - VFP_BASE) // VFP_STRIDE # 255
|
||||||
|
|
||||||
# Get/SetClockBoostTable (0x23F1B133 / 0x0733E009)
|
# Get/SetClockBoostTable (0x23F1B133 / 0x0733E009)
|
||||||
CT_SIZE = 0x2420
|
CT_SIZE = 0x2420
|
||||||
CT_BASE = 0x44
|
CT_BASE = 0x44
|
||||||
CT_STRIDE = 0x24 # 36 bytes
|
CT_STRIDE = 0x24 # 36 bytes
|
||||||
CT_DELTA_OFF = 0x14 # freqDelta offset within entry
|
CT_DELTA_OFF = 0x14 # freqDelta offset within entry
|
||||||
CT_MAX_ENTRIES = (CT_SIZE - CT_BASE) // CT_STRIDE # 255
|
CT_MAX_ENTRIES = (CT_SIZE - CT_BASE) // CT_STRIDE # 255
|
||||||
|
|
||||||
# GetClockBoostMask (0x507B4B59)
|
# GetClockBoostMask (0x507B4B59)
|
||||||
MASK_SIZE = 0x182C
|
MASK_SIZE = 0x182C
|
||||||
|
|
||||||
# Other structs
|
# Other structs
|
||||||
VOLT_SIZE = 0x004C
|
VOLT_SIZE = 0x004C
|
||||||
RANGES_SIZE = 0x0928
|
RANGES_SIZE = 0x0928
|
||||||
PERF_SIZE = 0x030C
|
PERF_SIZE = 0x030C
|
||||||
VBOOST_SIZE = 0x0028
|
VBOOST_SIZE = 0x0028
|
||||||
|
|
||||||
# Mask location within VFP/CT structs
|
# Mask location within VFP/CT structs
|
||||||
MASK_OFFSET = 0x04
|
MASK_OFFSET = 0x04
|
||||||
MASK_BYTES = 32 # 256 bits — covers up to 256 points
|
MASK_BYTES = 32 # 256 bits — covers up to 256 points
|
||||||
|
|
||||||
# Safety constants
|
# Safety constants
|
||||||
MAX_DELTA_KHZ = 300_000 # ±300 MHz hard cap for safety
|
MAX_DELTA_KHZ = 300_000 # ±300 MHz hard cap for safety
|
||||||
|
|
||||||
SNAPSHOT_DIR = os.path.expanduser("~/.cache/nv_vfcurve")
|
SNAPSHOT_DIR = os.path.expanduser("~/.cache/nv_vfcurve")
|
||||||
|
|
||||||
@@ -205,6 +202,7 @@ SNAPSHOT_DIR = os.path.expanduser("~/.cache/nv_vfcurve")
|
|||||||
# GPU initialization
|
# GPU initialization
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
def init_gpu() -> tuple:
|
def init_gpu() -> tuple:
|
||||||
"""Initialize NvAPI, enumerate GPUs, return (handle, name)."""
|
"""Initialize NvAPI, enumerate GPUs, return (handle, name)."""
|
||||||
init_fn = nvfunc(FUNC["Initialize"], 0)
|
init_fn = nvfunc(FUNC["Initialize"], 0)
|
||||||
@@ -236,18 +234,20 @@ def init_gpu() -> tuple:
|
|||||||
# also distinguishes GPU core vs memory clock domains.
|
# also distinguishes GPU core vs memory clock domains.
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
class BoostMask:
|
class BoostMask:
|
||||||
"""Parsed GetClockBoostMask data.
|
"""Parsed GetClockBoostMask data.
|
||||||
|
|
||||||
Provides the raw mask bytes for copying into other calls, plus
|
Provides the raw mask bytes for copying into other calls, plus
|
||||||
parsed per-entry enabled info for filtering.
|
parsed per-entry enabled info for filtering.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, raw: bytes):
|
def __init__(self, raw: bytes):
|
||||||
self.raw = raw
|
self.raw = raw
|
||||||
self.size = len(raw)
|
self.size = len(raw)
|
||||||
|
|
||||||
# The mask field at offset 0x04, 16 bytes — same position as in VFP/CT structs
|
# The mask field at offset 0x04, 16 bytes — same position as in VFP/CT structs
|
||||||
self.mask_bytes = raw[MASK_OFFSET:MASK_OFFSET + MASK_BYTES]
|
self.mask_bytes = raw[MASK_OFFSET : MASK_OFFSET + MASK_BYTES]
|
||||||
|
|
||||||
self.entries = []
|
self.entries = []
|
||||||
self._parse_entries()
|
self._parse_entries()
|
||||||
@@ -260,7 +260,7 @@ class BoostMask:
|
|||||||
enabled = bool(self.mask_bytes[byte_idx] & (1 << bit_idx))
|
enabled = bool(self.mask_bytes[byte_idx] & (1 << bit_idx))
|
||||||
self.entries.append({"index": i, "enabled": enabled})
|
self.entries.append({"index": i, "enabled": enabled})
|
||||||
|
|
||||||
def get_enabled_indices(self) -> List[int]:
|
def get_enabled_indices(self) -> list[int]:
|
||||||
"""Return list of point indices that are enabled in the mask."""
|
"""Return list of point indices that are enabled in the mask."""
|
||||||
return [e["index"] for e in self.entries if e["enabled"]]
|
return [e["index"] for e in self.entries if e["enabled"]]
|
||||||
|
|
||||||
@@ -273,12 +273,13 @@ class BoostMask:
|
|||||||
buf[offset + i] = self.mask_bytes[i]
|
buf[offset + i] = self.mask_bytes[i]
|
||||||
|
|
||||||
|
|
||||||
def read_boost_mask(gpu) -> Tuple[Optional[BoostMask], str]:
|
def read_boost_mask(gpu) -> tuple[BoostMask | None, str]:
|
||||||
"""Read the clock boost mask — the canonical source of active point info.
|
"""Read the clock boost mask — the canonical source of active point info.
|
||||||
|
|
||||||
Per nvapioc, this mask must be copied into VFP and ClockBoostTable calls.
|
Per nvapioc, this mask must be copied into VFP and ClockBoostTable calls.
|
||||||
Using all-0xFF works on some GPUs (Blackwell) but fails on others (Pascal).
|
Using all-0xFF works on some GPUs (Blackwell) but fails on others (Pascal).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def fill(buf):
|
def fill(buf):
|
||||||
for i in range(MASK_OFFSET, MASK_OFFSET + MASK_BYTES):
|
for i in range(MASK_OFFSET, MASK_OFFSET + MASK_BYTES):
|
||||||
buf[i] = 0xFF
|
buf[i] = 0xFF
|
||||||
@@ -294,20 +295,22 @@ def read_boost_mask(gpu) -> Tuple[Optional[BoostMask], str]:
|
|||||||
# Point classification — GPU core vs memory
|
# Point classification — GPU core vs memory
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
class CurveInfo:
|
class CurveInfo:
|
||||||
"""Holds classified point information for the GPU's V/F curve.
|
"""Holds classified point information for the GPU's V/F curve.
|
||||||
|
|
||||||
Combines data from GetClockBoostMask, GetVFPCurve, and GetClockBoostTable
|
Combines data from GetClockBoostMask, GetVFPCurve, and GetClockBoostTable
|
||||||
to determine which points are GPU core and which are memory.
|
to determine which points are GPU core and which are memory.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.gpu_points: List[int] = [] # GPU core V/F point indices
|
self.gpu_points: list[int] = [] # GPU core V/F point indices
|
||||||
self.mem_points: List[int] = [] # Memory V/F point indices
|
self.mem_points: list[int] = [] # Memory V/F point indices
|
||||||
self.total_points: int = 0 # Total populated entries
|
self.total_points: int = 0 # Total populated entries
|
||||||
self.mask: Optional[BoostMask] = None
|
self.mask: BoostMask | None = None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def build(gpu, mask: Optional[BoostMask] = None) -> 'CurveInfo':
|
def build(gpu, mask: BoostMask | None = None) -> "CurveInfo":
|
||||||
"""Classify all points by reading CT field_00 and VFP data.
|
"""Classify all points by reading CT field_00 and VFP data.
|
||||||
|
|
||||||
field_00 == 0: GPU core data point
|
field_00 == 0: GPU core data point
|
||||||
@@ -340,7 +343,7 @@ class CurveInfo:
|
|||||||
has_vfp_data = False
|
has_vfp_data = False
|
||||||
if vfp_points and i < len(vfp_points):
|
if vfp_points and i < len(vfp_points):
|
||||||
f, v = vfp_points[i]
|
f, v = vfp_points[i]
|
||||||
has_vfp_data = (f > 0 or v > 0)
|
has_vfp_data = f > 0 or v > 0
|
||||||
|
|
||||||
has_ct_data = False
|
has_ct_data = False
|
||||||
for j in range(9):
|
for j in range(9):
|
||||||
@@ -378,13 +381,15 @@ class CurveInfo:
|
|||||||
# Data readers (mask-aware)
|
# Data readers (mask-aware)
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
def _fill_mask_from_boost(buf, mask: BoostMask):
|
def _fill_mask_from_boost(buf, mask: BoostMask):
|
||||||
"""Copy boost mask into buffer."""
|
"""Copy boost mask into buffer."""
|
||||||
mask.copy_mask_into(buf)
|
mask.copy_mask_into(buf)
|
||||||
|
|
||||||
|
|
||||||
def _read_vfp_with_mask(gpu, mask: Optional[BoostMask]) -> Optional[List[Tuple[int, int]]]:
|
def _read_vfp_with_mask(gpu, mask: BoostMask | None) -> list[tuple[int, int]] | None:
|
||||||
"""Read VFP curve using the canonical boost mask."""
|
"""Read VFP curve using the canonical boost mask."""
|
||||||
|
|
||||||
def fill(buf):
|
def fill(buf):
|
||||||
_fill_mask_from_boost(buf, mask)
|
_fill_mask_from_boost(buf, mask)
|
||||||
|
|
||||||
@@ -403,8 +408,9 @@ def _read_vfp_with_mask(gpu, mask: Optional[BoostMask]) -> Optional[List[Tuple[i
|
|||||||
return points
|
return points
|
||||||
|
|
||||||
|
|
||||||
def _read_clock_table_raw_with_mask(gpu, mask: Optional[BoostMask]) -> Optional[bytes]:
|
def _read_clock_table_raw_with_mask(gpu, mask: BoostMask | None) -> bytes | None:
|
||||||
"""Read raw ClockBoostTable using the canonical boost mask."""
|
"""Read raw ClockBoostTable using the canonical boost mask."""
|
||||||
|
|
||||||
def fill(buf):
|
def fill(buf):
|
||||||
_fill_mask_from_boost(buf, mask)
|
_fill_mask_from_boost(buf, mask)
|
||||||
|
|
||||||
@@ -412,13 +418,14 @@ def _read_clock_table_raw_with_mask(gpu, mask: Optional[BoostMask]) -> Optional[
|
|||||||
return d if d else None
|
return d if d else None
|
||||||
|
|
||||||
|
|
||||||
def read_vfp_curve(gpu, mask: Optional[BoostMask] = None,
|
def read_vfp_curve(
|
||||||
curve_info: Optional[CurveInfo] = None
|
gpu, mask: BoostMask | None = None, curve_info: CurveInfo | None = None
|
||||||
) -> Tuple[Optional[List[Tuple[int, int]]], str]:
|
) -> tuple[list[tuple[int, int]] | None, str]:
|
||||||
"""Read V/F curve (frequency + voltage pairs).
|
"""Read V/F curve (frequency + voltage pairs).
|
||||||
|
|
||||||
Returns up to 255 entries. Use curve_info to determine which are GPU/mem.
|
Returns up to 255 entries. Use curve_info to determine which are GPU/mem.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def fill(buf):
|
def fill(buf):
|
||||||
_fill_mask_from_boost(buf, mask)
|
_fill_mask_from_boost(buf, mask)
|
||||||
|
|
||||||
@@ -442,18 +449,20 @@ def read_vfp_curve(gpu, mask: Optional[BoostMask] = None,
|
|||||||
return points, "OK"
|
return points, "OK"
|
||||||
|
|
||||||
|
|
||||||
def read_clock_table_raw(gpu, mask: Optional[BoostMask] = None
|
def read_clock_table_raw(
|
||||||
) -> Tuple[Optional[bytes], str]:
|
gpu, mask: BoostMask | None = None
|
||||||
|
) -> tuple[bytes | None, str]:
|
||||||
"""Read the raw ClockBoostTable buffer."""
|
"""Read the raw ClockBoostTable buffer."""
|
||||||
|
|
||||||
def fill(buf):
|
def fill(buf):
|
||||||
_fill_mask_from_boost(buf, mask)
|
_fill_mask_from_boost(buf, mask)
|
||||||
|
|
||||||
return nvcall(FUNC["GetClockBoostTable"], gpu, CT_SIZE, ver=1, pre_fill=fill)
|
return nvcall(FUNC["GetClockBoostTable"], gpu, CT_SIZE, ver=1, pre_fill=fill)
|
||||||
|
|
||||||
|
|
||||||
def read_clock_offsets(gpu, mask: Optional[BoostMask] = None,
|
def read_clock_offsets(
|
||||||
curve_info: Optional[CurveInfo] = None
|
gpu, mask: BoostMask | None = None, curve_info: CurveInfo | None = None
|
||||||
) -> Tuple[Optional[List[int]], str]:
|
) -> tuple[list[int] | None, str]:
|
||||||
"""Read per-point frequency offsets from the ClockBoostTable."""
|
"""Read per-point frequency offsets from the ClockBoostTable."""
|
||||||
d, err = read_clock_table_raw(gpu, mask)
|
d, err = read_clock_table_raw(gpu, mask)
|
||||||
if not d:
|
if not d:
|
||||||
@@ -482,14 +491,18 @@ def read_clock_entry_full(data: bytes, point: int) -> dict:
|
|||||||
for j in range(9):
|
for j in range(9):
|
||||||
off = base + j * 4
|
off = base + j * 4
|
||||||
if j == 5:
|
if j == 5:
|
||||||
fields[f"field_{j:02d}_0x{j*4:02X}"] = struct.unpack_from("<i", data, off)[0]
|
fields[f"field_{j:02d}_0x{j * 4:02X}"] = struct.unpack_from(
|
||||||
|
"<i", data, off
|
||||||
|
)[0]
|
||||||
else:
|
else:
|
||||||
fields[f"field_{j:02d}_0x{j*4:02X}"] = struct.unpack_from("<I", data, off)[0]
|
fields[f"field_{j:02d}_0x{j * 4:02X}"] = struct.unpack_from(
|
||||||
|
"<I", data, off
|
||||||
|
)[0]
|
||||||
fields["freqDelta_kHz"] = fields["field_05_0x14"]
|
fields["freqDelta_kHz"] = fields["field_05_0x14"]
|
||||||
return fields
|
return fields
|
||||||
|
|
||||||
|
|
||||||
def read_voltage(gpu) -> Tuple[Optional[int], str]:
|
def read_voltage(gpu) -> tuple[int | None, str]:
|
||||||
"""Read current GPU core voltage in µV."""
|
"""Read current GPU core voltage in µV."""
|
||||||
d, err = nvcall(FUNC["GetCurrentVoltage"], gpu, VOLT_SIZE, ver=1)
|
d, err = nvcall(FUNC["GetCurrentVoltage"], gpu, VOLT_SIZE, ver=1)
|
||||||
if not d:
|
if not d:
|
||||||
@@ -497,7 +510,7 @@ def read_voltage(gpu) -> Tuple[Optional[int], str]:
|
|||||||
return struct.unpack_from("<I", d, 0x28)[0], "OK"
|
return struct.unpack_from("<I", d, 0x28)[0], "OK"
|
||||||
|
|
||||||
|
|
||||||
def read_clock_ranges(gpu) -> Tuple[Optional[dict], str]:
|
def read_clock_ranges(gpu) -> tuple[dict | None, str]:
|
||||||
"""Read clock domain min/max offset ranges."""
|
"""Read clock domain min/max offset ranges."""
|
||||||
d, err = nvcall(FUNC["GetClockBoostRanges"], gpu, RANGES_SIZE, ver=1)
|
d, err = nvcall(FUNC["GetClockBoostRanges"], gpu, RANGES_SIZE, ver=1)
|
||||||
if not d:
|
if not d:
|
||||||
@@ -508,8 +521,7 @@ def read_clock_ranges(gpu) -> Tuple[Optional[dict], str]:
|
|||||||
base = 0x08 + i * 0x48
|
base = 0x08 + i * 0x48
|
||||||
if base + 0x48 > len(d):
|
if base + 0x48 > len(d):
|
||||||
break
|
break
|
||||||
words = [struct.unpack_from("<i", d, base + j)[0]
|
words = [struct.unpack_from("<i", d, base + j)[0] for j in range(0, 0x48, 4)]
|
||||||
for j in range(0, 0x48, 4)]
|
|
||||||
domains.append(words)
|
domains.append(words)
|
||||||
return {"num_domains": num, "domains": domains}, "OK"
|
return {"num_domains": num, "domains": domains}, "OK"
|
||||||
|
|
||||||
@@ -518,14 +530,17 @@ def read_clock_ranges(gpu) -> Tuple[Optional[dict], str]:
|
|||||||
# Mask bit helpers
|
# Mask bit helpers
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
def set_mask_bit(buf, point: int, offset=MASK_OFFSET):
|
def set_mask_bit(buf, point: int, offset=MASK_OFFSET):
|
||||||
"""Set a single bit in the mask field."""
|
"""Set a single bit in the mask field."""
|
||||||
byte_idx = offset + (point // 8)
|
byte_idx = offset + (point // 8)
|
||||||
bit_idx = point % 8
|
bit_idx = point % 8
|
||||||
buf[byte_idx] = int.from_bytes(buf[byte_idx:byte_idx+1], 'little') | (1 << bit_idx)
|
buf[byte_idx] = int.from_bytes(buf[byte_idx : byte_idx + 1], "little") | (
|
||||||
|
1 << bit_idx
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def set_mask_bits(buf, points: Set[int], offset=MASK_OFFSET):
|
def set_mask_bits(buf, points: set[int], offset=MASK_OFFSET):
|
||||||
"""Set mask bits for a set of points."""
|
"""Set mask bits for a set of points."""
|
||||||
for p in points:
|
for p in points:
|
||||||
set_mask_bit(buf, p, offset)
|
set_mask_bit(buf, p, offset)
|
||||||
@@ -535,11 +550,12 @@ def set_mask_bits(buf, points: Set[int], offset=MASK_OFFSET):
|
|||||||
# Write operations
|
# Write operations
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
def build_write_buffer(
|
def build_write_buffer(
|
||||||
gpu,
|
gpu,
|
||||||
point_deltas: dict,
|
point_deltas: dict,
|
||||||
mask: Optional[BoostMask] = None,
|
mask: BoostMask | None = None,
|
||||||
) -> Tuple[Optional[ctypes.Array], str]:
|
) -> tuple[ctypes.Array | None, str]:
|
||||||
"""Build a SetClockBoostTable buffer with specified per-point deltas.
|
"""Build a SetClockBoostTable buffer with specified per-point deltas.
|
||||||
|
|
||||||
Strategy: read the current ClockBoostTable (using canonical mask),
|
Strategy: read the current ClockBoostTable (using canonical mask),
|
||||||
@@ -576,9 +592,9 @@ def build_write_buffer(
|
|||||||
def write_clock_offsets(
|
def write_clock_offsets(
|
||||||
gpu,
|
gpu,
|
||||||
point_deltas: dict,
|
point_deltas: dict,
|
||||||
mask: Optional[BoostMask] = None,
|
mask: BoostMask | None = None,
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
) -> Tuple[int, str]:
|
) -> tuple[int, str]:
|
||||||
"""Write per-point frequency offsets via SetClockBoostTable."""
|
"""Write per-point frequency offsets via SetClockBoostTable."""
|
||||||
buf, err = build_write_buffer(gpu, point_deltas, mask)
|
buf, err = build_write_buffer(gpu, point_deltas, mask)
|
||||||
if buf is None:
|
if buf is None:
|
||||||
@@ -595,9 +611,10 @@ def write_clock_offsets(
|
|||||||
# Safety checks
|
# Safety checks
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
def validate_write_request(point_deltas: dict,
|
|
||||||
curve_info: Optional[CurveInfo] = None
|
def validate_write_request(
|
||||||
) -> Optional[str]:
|
point_deltas: dict, curve_info: CurveInfo | None = None
|
||||||
|
) -> str | None:
|
||||||
"""Return an error message if the write request is unsafe, else None."""
|
"""Return an error message if the write request is unsafe, else None."""
|
||||||
mem_points = set()
|
mem_points = set()
|
||||||
if curve_info:
|
if curve_info:
|
||||||
@@ -608,14 +625,18 @@ def validate_write_request(point_deltas: dict,
|
|||||||
return f"Point {point} out of range (0–{CT_MAX_ENTRIES - 1})"
|
return f"Point {point} out of range (0–{CT_MAX_ENTRIES - 1})"
|
||||||
|
|
||||||
if point in mem_points:
|
if point in mem_points:
|
||||||
return (f"Point {point} is a memory clock entry. "
|
return (
|
||||||
"Memory offsets use a different mechanism (NVML). "
|
f"Point {point} is a memory clock entry. "
|
||||||
"Use --force if you really mean it.")
|
"Memory offsets use a different mechanism (NVML). "
|
||||||
|
"Use --force if you really mean it."
|
||||||
|
)
|
||||||
|
|
||||||
if abs(delta_khz) > MAX_DELTA_KHZ:
|
if abs(delta_khz) > MAX_DELTA_KHZ:
|
||||||
return (f"Delta {delta_khz/1000:+.0f} MHz for point {point} exceeds "
|
return (
|
||||||
f"safety limit of ±{MAX_DELTA_KHZ/1000:.0f} MHz. "
|
f"Delta {delta_khz / 1000:+.0f} MHz for point {point} exceeds "
|
||||||
"Use --max-delta to raise the limit if needed.")
|
f"safety limit of ±{MAX_DELTA_KHZ / 1000:.0f} MHz. "
|
||||||
|
"Use --max-delta to raise the limit if needed."
|
||||||
|
)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -624,11 +645,12 @@ def validate_write_request(point_deltas: dict,
|
|||||||
# Hex dump utility
|
# Hex dump utility
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
def hexdump(data: bytes, start: int, length: int, cols: int = 16) -> str:
|
def hexdump(data: bytes, start: int, length: int, cols: int = 16) -> str:
|
||||||
lines = []
|
lines = []
|
||||||
end = min(start + length, len(data))
|
end = min(start + length, len(data))
|
||||||
for off in range(start, end, cols):
|
for off in range(start, end, cols):
|
||||||
chunk = data[off:off + cols]
|
chunk = data[off : off + cols]
|
||||||
hx = " ".join(f"{b:02x}" for b in chunk)
|
hx = " ".join(f"{b:02x}" for b in chunk)
|
||||||
asc = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk)
|
asc = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk)
|
||||||
lines.append(f" {off:04x}: {hx:<{cols * 3}} {asc}")
|
lines.append(f" {off:04x}: {hx:<{cols * 3}} {asc}")
|
||||||
@@ -639,7 +661,8 @@ def hexdump(data: bytes, start: int, length: int, cols: int = 16) -> str:
|
|||||||
# Snapshot save/restore
|
# Snapshot save/restore
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
def snapshot_save(gpu, gpu_name: str, mask: Optional[BoostMask] = None):
|
|
||||||
|
def snapshot_save(gpu, gpu_name: str, mask: BoostMask | None = None):
|
||||||
"""Save the current ClockBoostTable to disk."""
|
"""Save the current ClockBoostTable to disk."""
|
||||||
raw, err = read_clock_table_raw(gpu, mask)
|
raw, err = read_clock_table_raw(gpu, mask)
|
||||||
if not raw:
|
if not raw:
|
||||||
@@ -674,7 +697,7 @@ def snapshot_save(gpu, gpu_name: str, mask: Optional[BoostMask] = None):
|
|||||||
with open(meta_fname, "w") as f:
|
with open(meta_fname, "w") as f:
|
||||||
json.dump(meta, f, indent=2)
|
json.dump(meta, f, indent=2)
|
||||||
|
|
||||||
print(f"Snapshot saved:")
|
print("Snapshot saved:")
|
||||||
print(f" Binary: {fname}")
|
print(f" Binary: {fname}")
|
||||||
print(f" Metadata: {meta_fname}")
|
print(f" Metadata: {meta_fname}")
|
||||||
print(f" Size: {len(raw)} bytes")
|
print(f" Size: {len(raw)} bytes")
|
||||||
@@ -682,7 +705,7 @@ def snapshot_save(gpu, gpu_name: str, mask: Optional[BoostMask] = None):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def snapshot_restore(gpu, mask: Optional[BoostMask] = None, filepath: str = None):
|
def snapshot_restore(gpu, mask: BoostMask | None = None, filepath: str = None):
|
||||||
"""Restore a ClockBoostTable snapshot from disk."""
|
"""Restore a ClockBoostTable snapshot from disk."""
|
||||||
if filepath is None:
|
if filepath is None:
|
||||||
if not os.path.isdir(SNAPSHOT_DIR):
|
if not os.path.isdir(SNAPSHOT_DIR):
|
||||||
@@ -730,7 +753,8 @@ def snapshot_restore(gpu, mask: Optional[BoostMask] = None, filepath: str = None
|
|||||||
# Diagnostics
|
# Diagnostics
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
def run_diagnostics(gpu, gpu_name, mask: Optional[BoostMask] = None):
|
|
||||||
|
def run_diagnostics(gpu, gpu_name, mask: BoostMask | None = None):
|
||||||
"""Probe all known functions and report results."""
|
"""Probe all known functions and report results."""
|
||||||
print(f"GPU: {gpu_name}")
|
print(f"GPU: {gpu_name}")
|
||||||
print()
|
print()
|
||||||
@@ -739,14 +763,14 @@ def run_diagnostics(gpu, gpu_name, mask: Optional[BoostMask] = None):
|
|||||||
print("=== Function probe ===")
|
print("=== Function probe ===")
|
||||||
print()
|
print()
|
||||||
probes = [
|
probes = [
|
||||||
("GetVFPCurve", FUNC["GetVFPCurve"], VFP_SIZE, 1),
|
("GetVFPCurve", FUNC["GetVFPCurve"], VFP_SIZE, 1),
|
||||||
("GetClockBoostMask", FUNC["GetClockBoostMask"], MASK_SIZE, 1),
|
("GetClockBoostMask", FUNC["GetClockBoostMask"], MASK_SIZE, 1),
|
||||||
("GetClockBoostTable", FUNC["GetClockBoostTable"], CT_SIZE, 1),
|
("GetClockBoostTable", FUNC["GetClockBoostTable"], CT_SIZE, 1),
|
||||||
("GetCurrentVoltage", FUNC["GetCurrentVoltage"], VOLT_SIZE, 1),
|
("GetCurrentVoltage", FUNC["GetCurrentVoltage"], VOLT_SIZE, 1),
|
||||||
("GetClockBoostRanges", FUNC["GetClockBoostRanges"], RANGES_SIZE, 1),
|
("GetClockBoostRanges", FUNC["GetClockBoostRanges"], RANGES_SIZE, 1),
|
||||||
("GetPerfLimits", FUNC["GetPerfLimits"], PERF_SIZE, 2),
|
("GetPerfLimits", FUNC["GetPerfLimits"], PERF_SIZE, 2),
|
||||||
("GetVoltBoostPercent", FUNC["GetVoltBoostPercent"], VBOOST_SIZE, 1),
|
("GetVoltBoostPercent", FUNC["GetVoltBoostPercent"], VBOOST_SIZE, 1),
|
||||||
("SetClockBoostTable", FUNC["SetClockBoostTable"], CT_SIZE, 1),
|
("SetClockBoostTable", FUNC["SetClockBoostTable"], CT_SIZE, 1),
|
||||||
]
|
]
|
||||||
for name, fid, size, ver in probes:
|
for name, fid, size, ver in probes:
|
||||||
ptr = QI(fid)
|
ptr = QI(fid)
|
||||||
@@ -768,7 +792,9 @@ def run_diagnostics(gpu, gpu_name, mask: Optional[BoostMask] = None):
|
|||||||
|
|
||||||
# Step 3: test reads with the proper mask
|
# Step 3: test reads with the proper mask
|
||||||
needs_mask_fns = {
|
needs_mask_fns = {
|
||||||
FUNC["GetVFPCurve"], FUNC["GetClockBoostMask"], FUNC["GetClockBoostTable"]
|
FUNC["GetVFPCurve"],
|
||||||
|
FUNC["GetClockBoostMask"],
|
||||||
|
FUNC["GetClockBoostTable"],
|
||||||
}
|
}
|
||||||
|
|
||||||
print()
|
print()
|
||||||
@@ -817,8 +843,10 @@ def run_diagnostics(gpu, gpu_name, mask: Optional[BoostMask] = None):
|
|||||||
# Output formatting
|
# Output formatting
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
def print_curve(points, offsets, voltage, curve_info: Optional[CurveInfo] = None,
|
|
||||||
full=False):
|
def print_curve(
|
||||||
|
points, offsets, voltage, curve_info: CurveInfo | None = None, full=False
|
||||||
|
):
|
||||||
"""Print formatted V/F curve table."""
|
"""Print formatted V/F curve table."""
|
||||||
if voltage:
|
if voltage:
|
||||||
print(f"Current voltage: {voltage / 1000:.1f} mV")
|
print(f"Current voltage: {voltage / 1000:.1f} mV")
|
||||||
@@ -845,9 +873,7 @@ def print_curve(points, offsets, voltage, curve_info: Optional[CurveInfo] = None
|
|||||||
for i, (f, v) in enumerate(points):
|
for i, (f, v) in enumerate(points):
|
||||||
if f == 0 and v == 0:
|
if f == 0 and v == 0:
|
||||||
continue
|
continue
|
||||||
if i in mem_set:
|
if i in mem_set or f != prev_freq or i == len(points) - 1:
|
||||||
show.append(i)
|
|
||||||
elif f != prev_freq or i == len(points) - 1:
|
|
||||||
show.append(i)
|
show.append(i)
|
||||||
prev_freq = f
|
prev_freq = f
|
||||||
|
|
||||||
@@ -882,52 +908,78 @@ def print_curve(points, offsets, voltage, curve_info: Optional[CurveInfo] = None
|
|||||||
|
|
||||||
# Summary
|
# Summary
|
||||||
if curve_info and curve_info.gpu_points:
|
if curve_info and curve_info.gpu_points:
|
||||||
gpu_data = [(points[i][0], points[i][1]) for i in curve_info.gpu_points
|
gpu_data = [
|
||||||
if i < len(points) and points[i][0] > 0]
|
(points[i][0], points[i][1])
|
||||||
|
for i in curve_info.gpu_points
|
||||||
|
if i < len(points) and points[i][0] > 0
|
||||||
|
]
|
||||||
if gpu_data:
|
if gpu_data:
|
||||||
freqs = [f for f, v in gpu_data]
|
freqs = [f for f, v in gpu_data]
|
||||||
volts = [v for f, v in gpu_data]
|
volts = [v for f, v in gpu_data]
|
||||||
print()
|
print()
|
||||||
print(f"GPU core: {min(freqs)/1000:.0f} – {max(freqs)/1000:.0f} MHz, "
|
print(
|
||||||
f"{min(volts)/1000:.0f} – {max(volts)/1000:.0f} mV "
|
f"GPU core: {min(freqs) / 1000:.0f} – {max(freqs) / 1000:.0f} MHz, "
|
||||||
f"({len(gpu_data)} points)")
|
f"{min(volts) / 1000:.0f} – {max(volts) / 1000:.0f} mV "
|
||||||
|
f"({len(gpu_data)} points)"
|
||||||
|
)
|
||||||
|
|
||||||
if curve_info and curve_info.mem_points:
|
if curve_info and curve_info.mem_points:
|
||||||
mem_data = [(points[i][0], points[i][1]) for i in curve_info.mem_points
|
mem_data = [
|
||||||
if i < len(points) and points[i][0] > 0]
|
(points[i][0], points[i][1])
|
||||||
|
for i in curve_info.mem_points
|
||||||
|
if i < len(points) and points[i][0] > 0
|
||||||
|
]
|
||||||
if mem_data:
|
if mem_data:
|
||||||
freqs = [f for f, v in mem_data]
|
freqs = [f for f, v in mem_data]
|
||||||
volts = [v for f, v in mem_data]
|
volts = [v for f, v in mem_data]
|
||||||
print(f"Memory: {min(freqs)/1000:.0f} – {max(freqs)/1000:.0f} MHz, "
|
print(
|
||||||
f"{min(volts)/1000:.0f} – {max(volts)/1000:.0f} mV "
|
f"Memory: {min(freqs) / 1000:.0f} – {max(freqs) / 1000:.0f} MHz, "
|
||||||
f"({len(mem_data)} points)")
|
f"{min(volts) / 1000:.0f} – {max(volts) / 1000:.0f} mV "
|
||||||
|
f"({len(mem_data)} points)"
|
||||||
|
)
|
||||||
|
|
||||||
if offsets:
|
if offsets:
|
||||||
gpu_indices = set(curve_info.gpu_points) if curve_info else set(range(len(offsets)))
|
gpu_indices = (
|
||||||
gpu_offsets = [offsets[i] for i in gpu_indices
|
set(curve_info.gpu_points) if curve_info else set(range(len(offsets)))
|
||||||
if i < len(offsets) and offsets[i] != 0]
|
)
|
||||||
|
gpu_offsets = [
|
||||||
|
offsets[i] for i in gpu_indices if i < len(offsets) and offsets[i] != 0
|
||||||
|
]
|
||||||
if gpu_offsets:
|
if gpu_offsets:
|
||||||
vals = set(gpu_offsets)
|
vals = set(gpu_offsets)
|
||||||
if len(vals) == 1:
|
if len(vals) == 1:
|
||||||
print(f"GPU offset: {next(iter(vals))/1000:+.0f} MHz "
|
print(
|
||||||
f"(uniform across {len(gpu_offsets)} points)")
|
f"GPU offset: {next(iter(vals)) / 1000:+.0f} MHz "
|
||||||
|
f"(uniform across {len(gpu_offsets)} points)"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
print(f"GPU offsets: {len(gpu_offsets)} points active "
|
print(
|
||||||
f"(range: {min(vals)/1000:+.0f} to {max(vals)/1000:+.0f} MHz)")
|
f"GPU offsets: {len(gpu_offsets)} points active "
|
||||||
|
f"(range: {min(vals) / 1000:+.0f} to {max(vals) / 1000:+.0f} MHz)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def output_json(gpu_name, points, offsets, voltage,
|
def output_json(
|
||||||
curve_info: Optional[CurveInfo] = None):
|
gpu_name, points, offsets, voltage, curve_info: CurveInfo | None = None
|
||||||
|
):
|
||||||
"""Output JSON format."""
|
"""Output JSON format."""
|
||||||
data = {
|
data = {
|
||||||
"gpu": gpu_name,
|
"gpu": gpu_name,
|
||||||
"current_voltage_uV": voltage,
|
"current_voltage_uV": voltage,
|
||||||
"layout": {
|
"layout": {
|
||||||
"vfp_curve": {"size": VFP_SIZE, "base": VFP_BASE,
|
"vfp_curve": {
|
||||||
"stride": VFP_STRIDE, "max_entries": VFP_MAX_ENTRIES},
|
"size": VFP_SIZE,
|
||||||
"clock_table": {"size": CT_SIZE, "base": CT_BASE,
|
"base": VFP_BASE,
|
||||||
"stride": CT_STRIDE, "delta_offset": CT_DELTA_OFF,
|
"stride": VFP_STRIDE,
|
||||||
"max_entries": CT_MAX_ENTRIES},
|
"max_entries": VFP_MAX_ENTRIES,
|
||||||
|
},
|
||||||
|
"clock_table": {
|
||||||
|
"size": CT_SIZE,
|
||||||
|
"base": CT_BASE,
|
||||||
|
"stride": CT_STRIDE,
|
||||||
|
"delta_offset": CT_DELTA_OFF,
|
||||||
|
"max_entries": CT_MAX_ENTRIES,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"curve_info": {
|
"curve_info": {
|
||||||
"gpu_points": curve_info.gpu_points if curve_info else [],
|
"gpu_points": curve_info.gpu_points if curve_info else [],
|
||||||
@@ -958,6 +1010,7 @@ def output_json(gpu_name, points, offsets, voltage,
|
|||||||
# Write command handler
|
# Write command handler
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
def cmd_write(gpu, gpu_name, args, mask, curve_info):
|
def cmd_write(gpu, gpu_name, args, mask, curve_info):
|
||||||
"""Handle write subcommand."""
|
"""Handle write subcommand."""
|
||||||
delta_khz = int(args.delta * 1000)
|
delta_khz = int(args.delta * 1000)
|
||||||
@@ -977,21 +1030,27 @@ def cmd_write(gpu, gpu_name, args, mask, curve_info):
|
|||||||
|
|
||||||
elif args.point is not None:
|
elif args.point is not None:
|
||||||
point_deltas[args.point] = delta_khz
|
point_deltas[args.point] = delta_khz
|
||||||
print(f"Target: point {args.point}, delta {args.delta:+.0f} MHz "
|
print(
|
||||||
f"({delta_khz:+d} kHz)")
|
f"Target: point {args.point}, delta {args.delta:+.0f} MHz "
|
||||||
|
f"({delta_khz:+d} kHz)"
|
||||||
|
)
|
||||||
|
|
||||||
elif args.range:
|
elif args.range:
|
||||||
start, end = args.range
|
start, end = args.range
|
||||||
for i in range(start, end + 1):
|
for i in range(start, end + 1):
|
||||||
point_deltas[i] = delta_khz
|
point_deltas[i] = delta_khz
|
||||||
print(f"Target: points {start}–{end} ({len(point_deltas)} points), "
|
print(
|
||||||
f"delta {args.delta:+.0f} MHz")
|
f"Target: points {start}–{end} ({len(point_deltas)} points), "
|
||||||
|
f"delta {args.delta:+.0f} MHz"
|
||||||
|
)
|
||||||
|
|
||||||
elif args.glob:
|
elif args.glob:
|
||||||
for i in gpu_points:
|
for i in gpu_points:
|
||||||
point_deltas[i] = delta_khz
|
point_deltas[i] = delta_khz
|
||||||
print(f"Target: all {len(point_deltas)} GPU core points, "
|
print(
|
||||||
f"delta {args.delta:+.0f} MHz")
|
f"Target: all {len(point_deltas)} GPU core points, "
|
||||||
|
f"delta {args.delta:+.0f} MHz"
|
||||||
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
print("Error: specify --point N, --range A-B, --global, or --reset")
|
print("Error: specify --point N, --range A-B, --global, or --reset")
|
||||||
@@ -1013,11 +1072,17 @@ def cmd_write(gpu, gpu_name, args, mask, curve_info):
|
|||||||
changed = 0
|
changed = 0
|
||||||
for point in sorted(point_deltas.keys()):
|
for point in sorted(point_deltas.keys()):
|
||||||
new = point_deltas[point]
|
new = point_deltas[point]
|
||||||
old = current_offsets[point] if current_offsets and point < len(current_offsets) else 0
|
old = (
|
||||||
|
current_offsets[point]
|
||||||
|
if current_offsets and point < len(current_offsets)
|
||||||
|
else 0
|
||||||
|
)
|
||||||
if old != new:
|
if old != new:
|
||||||
changed += 1
|
changed += 1
|
||||||
if changed <= 20:
|
if changed <= 20:
|
||||||
print(f" Point {point:3d}: {old/1000:+8.0f} MHz → {new/1000:+8.0f} MHz")
|
print(
|
||||||
|
f" Point {point:3d}: {old / 1000:+8.0f} MHz → {new / 1000:+8.0f} MHz"
|
||||||
|
)
|
||||||
if changed > 20:
|
if changed > 20:
|
||||||
print(f" ... and {changed - 20} more points")
|
print(f" ... and {changed - 20} more points")
|
||||||
if changed == 0:
|
if changed == 0:
|
||||||
@@ -1036,8 +1101,10 @@ def cmd_write(gpu, gpu_name, args, mask, curve_info):
|
|||||||
|
|
||||||
first_pt = min(point_deltas.keys())
|
first_pt = min(point_deltas.keys())
|
||||||
entry_off = CT_BASE + first_pt * CT_STRIDE
|
entry_off = CT_BASE + first_pt * CT_STRIDE
|
||||||
print(f"\nEntry for point {first_pt} (offset 0x{entry_off:04X}, "
|
print(
|
||||||
f"stride 0x{CT_STRIDE:02X}):")
|
f"\nEntry for point {first_pt} (offset 0x{entry_off:04X}, "
|
||||||
|
f"stride 0x{CT_STRIDE:02X}):"
|
||||||
|
)
|
||||||
print(hexdump(bytes(buf), entry_off, CT_STRIDE))
|
print(hexdump(bytes(buf), entry_off, CT_STRIDE))
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1070,8 +1137,10 @@ def cmd_write(gpu, gpu_name, args, mask, curve_info):
|
|||||||
actual = new_offsets[point] if point < len(new_offsets) else 0
|
actual = new_offsets[point] if point < len(new_offsets) else 0
|
||||||
if actual != expected:
|
if actual != expected:
|
||||||
mismatches += 1
|
mismatches += 1
|
||||||
print(f" MISMATCH point {point}: expected {expected/1000:+.0f} MHz, "
|
print(
|
||||||
f"got {actual/1000:+.0f} MHz")
|
f" MISMATCH point {point}: expected {expected / 1000:+.0f} MHz, "
|
||||||
|
f"got {actual / 1000:+.0f} MHz"
|
||||||
|
)
|
||||||
|
|
||||||
if mismatches == 0:
|
if mismatches == 0:
|
||||||
print(f"Verified: all {len(point_deltas)} points match expected values.")
|
print(f"Verified: all {len(point_deltas)} points match expected values.")
|
||||||
@@ -1083,6 +1152,7 @@ def cmd_write(gpu, gpu_name, args, mask, curve_info):
|
|||||||
# Verify command handler
|
# Verify command handler
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
def cmd_verify(gpu, gpu_name, args, mask, curve_info):
|
def cmd_verify(gpu, gpu_name, args, mask, curve_info):
|
||||||
"""Write-verify-read cycle for a single point or range."""
|
"""Write-verify-read cycle for a single point or range."""
|
||||||
delta_khz = int(args.delta * 1000)
|
delta_khz = int(args.delta * 1000)
|
||||||
@@ -1095,14 +1165,14 @@ def cmd_verify(gpu, gpu_name, args, mask, curve_info):
|
|||||||
print("Error: --point or --range required for verify mode")
|
print("Error: --point or --range required for verify mode")
|
||||||
return
|
return
|
||||||
|
|
||||||
point_deltas = {p: delta_khz for p in points}
|
point_deltas = dict.fromkeys(points, delta_khz)
|
||||||
|
|
||||||
err = validate_write_request(point_deltas, curve_info)
|
err = validate_write_request(point_deltas, curve_info)
|
||||||
if err:
|
if err:
|
||||||
print(f"Safety check FAILED: {err}")
|
print(f"Safety check FAILED: {err}")
|
||||||
return
|
return
|
||||||
|
|
||||||
print(f"=== Write-Verify Cycle ===")
|
print("=== Write-Verify Cycle ===")
|
||||||
print(f"GPU: {gpu_name}")
|
print(f"GPU: {gpu_name}")
|
||||||
if curve_info:
|
if curve_info:
|
||||||
print(f"Curve: {curve_info.describe()}")
|
print(f"Curve: {curve_info.describe()}")
|
||||||
@@ -1121,7 +1191,7 @@ def cmd_verify(gpu, gpu_name, args, mask, curve_info):
|
|||||||
for p in points[:5]:
|
for p in points[:5]:
|
||||||
entry = read_clock_entry_full(before_raw, p) if before_raw else {}
|
entry = read_clock_entry_full(before_raw, p) if before_raw else {}
|
||||||
off_val = before_offsets[p] if p < len(before_offsets) else 0
|
off_val = before_offsets[p] if p < len(before_offsets) else 0
|
||||||
print(f" Point {p:3d}: freqDelta = {off_val/1000:+8.0f} MHz")
|
print(f" Point {p:3d}: freqDelta = {off_val / 1000:+8.0f} MHz")
|
||||||
if entry:
|
if entry:
|
||||||
print(f" All fields: {entry}")
|
print(f" All fields: {entry}")
|
||||||
|
|
||||||
@@ -1156,8 +1226,10 @@ def cmd_verify(gpu, gpu_name, args, mask, curve_info):
|
|||||||
match = "OK" if actual == expected else "MISMATCH"
|
match = "OK" if actual == expected else "MISMATCH"
|
||||||
if actual != expected:
|
if actual != expected:
|
||||||
all_ok = False
|
all_ok = False
|
||||||
print(f" Point {p:3d}: expected {expected/1000:+8.0f} MHz, "
|
print(
|
||||||
f"got {actual/1000:+8.0f} MHz [{match}]")
|
f" Point {p:3d}: expected {expected / 1000:+8.0f} MHz, "
|
||||||
|
f"got {actual / 1000:+8.0f} MHz [{match}]"
|
||||||
|
)
|
||||||
|
|
||||||
# Step 5: Check for collateral damage
|
# Step 5: Check for collateral damage
|
||||||
print()
|
print()
|
||||||
@@ -1169,8 +1241,10 @@ def cmd_verify(gpu, gpu_name, args, mask, curve_info):
|
|||||||
continue
|
continue
|
||||||
if before_offsets[i] != after_offsets[i]:
|
if before_offsets[i] != after_offsets[i]:
|
||||||
collateral += 1
|
collateral += 1
|
||||||
print(f" WARNING: Point {i} changed unexpectedly: "
|
print(
|
||||||
f"{before_offsets[i]/1000:+.0f} → {after_offsets[i]/1000:+.0f} MHz")
|
f" WARNING: Point {i} changed unexpectedly: "
|
||||||
|
f"{before_offsets[i] / 1000:+.0f} → {after_offsets[i] / 1000:+.0f} MHz"
|
||||||
|
)
|
||||||
if collateral == 0:
|
if collateral == 0:
|
||||||
print(" No unintended changes detected.")
|
print(" No unintended changes detected.")
|
||||||
|
|
||||||
@@ -1187,14 +1261,16 @@ def cmd_verify(gpu, gpu_name, args, mask, curve_info):
|
|||||||
continue
|
continue
|
||||||
if before_entry[key] != after_entry[key]:
|
if before_entry[key] != after_entry[key]:
|
||||||
field_changes += 1
|
field_changes += 1
|
||||||
print(f" Point {p}, {key}: {before_entry[key]} → {after_entry[key]}")
|
print(
|
||||||
|
f" Point {p}, {key}: {before_entry[key]} → {after_entry[key]}"
|
||||||
|
)
|
||||||
if field_changes == 0:
|
if field_changes == 0:
|
||||||
print(" No unknown fields changed.")
|
print(" No unknown fields changed.")
|
||||||
|
|
||||||
# Step 7: Read voltage
|
# Step 7: Read voltage
|
||||||
voltage, _ = read_voltage(gpu)
|
voltage, _ = read_voltage(gpu)
|
||||||
if voltage:
|
if voltage:
|
||||||
print(f"\nCurrent voltage after write: {voltage/1000:.1f} mV")
|
print(f"\nCurrent voltage after write: {voltage / 1000:.1f} mV")
|
||||||
|
|
||||||
# Summary
|
# Summary
|
||||||
print()
|
print()
|
||||||
@@ -1215,6 +1291,7 @@ def cmd_verify(gpu, gpu_name, args, mask, curve_info):
|
|||||||
# Inspect command
|
# Inspect command
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
def cmd_inspect(gpu, gpu_name, args, mask, curve_info):
|
def cmd_inspect(gpu, gpu_name, args, mask, curve_info):
|
||||||
"""Show detailed field-level data for specific points."""
|
"""Show detailed field-level data for specific points."""
|
||||||
raw, err = read_clock_table_raw(gpu, mask)
|
raw, err = read_clock_table_raw(gpu, mask)
|
||||||
@@ -1246,8 +1323,9 @@ def cmd_inspect(gpu, gpu_name, args, mask, curve_info):
|
|||||||
print(f"GPU: {gpu_name}")
|
print(f"GPU: {gpu_name}")
|
||||||
if curve_info:
|
if curve_info:
|
||||||
print(f"Curve: {curve_info.describe()}")
|
print(f"Curve: {curve_info.describe()}")
|
||||||
print(f"ClockBoostTable entry detail (stride=0x{CT_STRIDE:02X}, "
|
print(
|
||||||
f"9 fields × 4 bytes)")
|
f"ClockBoostTable entry detail (stride=0x{CT_STRIDE:02X}, 9 fields × 4 bytes)"
|
||||||
|
)
|
||||||
print()
|
print()
|
||||||
|
|
||||||
for p in indices:
|
for p in indices:
|
||||||
@@ -1267,7 +1345,7 @@ def cmd_inspect(gpu, gpu_name, args, mask, curve_info):
|
|||||||
freq_str = ""
|
freq_str = ""
|
||||||
if vfp_points and p < len(vfp_points):
|
if vfp_points and p < len(vfp_points):
|
||||||
f, v = vfp_points[p]
|
f, v = vfp_points[p]
|
||||||
freq_str = f" (VFP: {f/1000:.0f} MHz @ {v/1000:.0f} mV)"
|
freq_str = f" (VFP: {f / 1000:.0f} MHz @ {v / 1000:.0f} mV)"
|
||||||
|
|
||||||
print(f"Point {p:3d} — buffer offset 0x{off:04X}{domain}{freq_str}")
|
print(f"Point {p:3d} — buffer offset 0x{off:04X}{domain}{freq_str}")
|
||||||
for key, val in entry.items():
|
for key, val in entry.items():
|
||||||
@@ -1275,8 +1353,10 @@ def cmd_inspect(gpu, gpu_name, args, mask, curve_info):
|
|||||||
continue
|
continue
|
||||||
marker = " ← freqDelta" if "0x14" in key else ""
|
marker = " ← freqDelta" if "0x14" in key else ""
|
||||||
if "0x14" in key:
|
if "0x14" in key:
|
||||||
print(f" {key}: {val:12d} (0x{val & 0xFFFFFFFF:08X})"
|
print(
|
||||||
f" = {val/1000:+.0f} MHz{marker}")
|
f" {key}: {val:12d} (0x{val & 0xFFFFFFFF:08X})"
|
||||||
|
f" = {val / 1000:+.0f} MHz{marker}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
print(f" {key}: {val:12d} (0x{val:08X})")
|
print(f" {key}: {val:12d} (0x{val:08X})")
|
||||||
print()
|
print()
|
||||||
@@ -1286,6 +1366,7 @@ def cmd_inspect(gpu, gpu_name, args, mask, curve_info):
|
|||||||
# Read command handler
|
# Read command handler
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
def cmd_read(gpu, gpu_name, args, mask, curve_info):
|
def cmd_read(gpu, gpu_name, args, mask, curve_info):
|
||||||
"""Handle read subcommand."""
|
"""Handle read subcommand."""
|
||||||
if args.diag:
|
if args.diag:
|
||||||
@@ -1309,10 +1390,13 @@ def cmd_read(gpu, gpu_name, args, mask, curve_info):
|
|||||||
print(f"GPU: {gpu_name}")
|
print(f"GPU: {gpu_name}")
|
||||||
|
|
||||||
if args.raw:
|
if args.raw:
|
||||||
|
|
||||||
def fill_vfp(buf):
|
def fill_vfp(buf):
|
||||||
_fill_mask_from_boost(buf, mask)
|
_fill_mask_from_boost(buf, mask)
|
||||||
vfp_raw, _ = nvcall(FUNC["GetVFPCurve"], gpu, VFP_SIZE,
|
|
||||||
ver=1, pre_fill=fill_vfp)
|
vfp_raw, _ = nvcall(
|
||||||
|
FUNC["GetVFPCurve"], gpu, VFP_SIZE, ver=1, pre_fill=fill_vfp
|
||||||
|
)
|
||||||
ct_raw, _ = read_clock_table_raw(gpu, mask)
|
ct_raw, _ = read_clock_table_raw(gpu, mask)
|
||||||
|
|
||||||
if vfp_raw:
|
if vfp_raw:
|
||||||
@@ -1348,7 +1432,8 @@ def cmd_read(gpu, gpu_name, args, mask, curve_info):
|
|||||||
# Argument parsing
|
# Argument parsing
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
def parse_range(s: str) -> Tuple[int, int]:
|
|
||||||
|
def parse_range(s: str) -> tuple[int, int]:
|
||||||
"""Parse 'A-B' into (A, B) tuple."""
|
"""Parse 'A-B' into (A, B) tuple."""
|
||||||
parts = s.split("-")
|
parts = s.split("-")
|
||||||
if len(parts) != 2:
|
if len(parts) != 2:
|
||||||
@@ -1360,7 +1445,9 @@ def parse_range(s: str) -> Tuple[int, int]:
|
|||||||
if a > b:
|
if a > b:
|
||||||
raise argparse.ArgumentTypeError(f"Start > end in range: {a}-{b}")
|
raise argparse.ArgumentTypeError(f"Start > end in range: {a}-{b}")
|
||||||
if a < 0 or b >= CT_MAX_ENTRIES:
|
if a < 0 or b >= CT_MAX_ENTRIES:
|
||||||
raise argparse.ArgumentTypeError(f"Range {a}-{b} outside 0–{CT_MAX_ENTRIES - 1}")
|
raise argparse.ArgumentTypeError(
|
||||||
|
f"Range {a}-{b} outside 0–{CT_MAX_ENTRIES - 1}"
|
||||||
|
)
|
||||||
return (a, b)
|
return (a, b)
|
||||||
|
|
||||||
|
|
||||||
@@ -1390,14 +1477,16 @@ Examples:
|
|||||||
|
|
||||||
# --- read ---
|
# --- read ---
|
||||||
p_read = sub.add_parser("read", help="Read V/F curve (default)")
|
p_read = sub.add_parser("read", help="Read V/F curve (default)")
|
||||||
p_read.add_argument("--full", action="store_true",
|
p_read.add_argument(
|
||||||
help="Show all points including empty slots")
|
"--full", action="store_true", help="Show all points including empty slots"
|
||||||
p_read.add_argument("--json", action="store_true",
|
)
|
||||||
help="JSON output with domain classification")
|
p_read.add_argument(
|
||||||
p_read.add_argument("--raw", action="store_true",
|
"--json", action="store_true", help="JSON output with domain classification"
|
||||||
help="Include hex dumps")
|
)
|
||||||
p_read.add_argument("--diag", action="store_true",
|
p_read.add_argument("--raw", action="store_true", help="Include hex dumps")
|
||||||
help="Probe all functions with mask comparison")
|
p_read.add_argument(
|
||||||
|
"--diag", action="store_true", help="Probe all functions with mask comparison"
|
||||||
|
)
|
||||||
|
|
||||||
# --- inspect ---
|
# --- inspect ---
|
||||||
p_insp = sub.add_parser("inspect", help="Show detailed entry fields")
|
p_insp = sub.add_parser("inspect", help="Show detailed entry fields")
|
||||||
@@ -1409,30 +1498,42 @@ Examples:
|
|||||||
tgt = p_write.add_mutually_exclusive_group()
|
tgt = p_write.add_mutually_exclusive_group()
|
||||||
tgt.add_argument("--point", type=int, help="Single point index")
|
tgt.add_argument("--point", type=int, help="Single point index")
|
||||||
tgt.add_argument("--range", type=parse_range, help="Point range A-B")
|
tgt.add_argument("--range", type=parse_range, help="Point range A-B")
|
||||||
tgt.add_argument("--global", dest="glob", action="store_true",
|
tgt.add_argument(
|
||||||
help="All GPU core points")
|
"--global", dest="glob", action="store_true", help="All GPU core points"
|
||||||
tgt.add_argument("--reset", action="store_true",
|
)
|
||||||
help="Reset all GPU core offsets to 0")
|
tgt.add_argument(
|
||||||
p_write.add_argument("--delta", type=float, default=0.0,
|
"--reset", action="store_true", help="Reset all GPU core offsets to 0"
|
||||||
help="Frequency offset in MHz (e.g. 15, -30)")
|
)
|
||||||
p_write.add_argument("--dry-run", action="store_true",
|
p_write.add_argument(
|
||||||
help="Preview changes without applying")
|
"--delta",
|
||||||
p_write.add_argument("--force", action="store_true",
|
type=float,
|
||||||
help="Allow modifying memory points")
|
default=0.0,
|
||||||
p_write.add_argument("--max-delta", type=float, default=300.0,
|
help="Frequency offset in MHz (e.g. 15, -30)",
|
||||||
help="Override safety limit (MHz, default 300)")
|
)
|
||||||
|
p_write.add_argument(
|
||||||
|
"--dry-run", action="store_true", help="Preview changes without applying"
|
||||||
|
)
|
||||||
|
p_write.add_argument(
|
||||||
|
"--force", action="store_true", help="Allow modifying memory points"
|
||||||
|
)
|
||||||
|
p_write.add_argument(
|
||||||
|
"--max-delta",
|
||||||
|
type=float,
|
||||||
|
default=300.0,
|
||||||
|
help="Override safety limit (MHz, default 300)",
|
||||||
|
)
|
||||||
|
|
||||||
# --- verify ---
|
# --- verify ---
|
||||||
p_ver = sub.add_parser("verify", help="Write-verify-read cycle")
|
p_ver = sub.add_parser("verify", help="Write-verify-read cycle")
|
||||||
p_ver.add_argument("--point", type=int, help="Single point index")
|
p_ver.add_argument("--point", type=int, help="Single point index")
|
||||||
p_ver.add_argument("--range", type=parse_range, help="Point range A-B")
|
p_ver.add_argument("--range", type=parse_range, help="Point range A-B")
|
||||||
p_ver.add_argument("--delta", type=float, required=True,
|
p_ver.add_argument(
|
||||||
help="Frequency offset in MHz")
|
"--delta", type=float, required=True, help="Frequency offset in MHz"
|
||||||
|
)
|
||||||
|
|
||||||
# --- snapshot ---
|
# --- snapshot ---
|
||||||
p_snap = sub.add_parser("snapshot", help="Save/restore ClockBoostTable")
|
p_snap = sub.add_parser("snapshot", help="Save/restore ClockBoostTable")
|
||||||
p_snap.add_argument("action", choices=["save", "restore"],
|
p_snap.add_argument("action", choices=["save", "restore"], help="save or restore")
|
||||||
help="save or restore")
|
|
||||||
p_snap.add_argument("--file", help="Snapshot file path (for restore)")
|
p_snap.add_argument("--file", help="Snapshot file path (for restore)")
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|||||||
@@ -0,0 +1,355 @@
|
|||||||
|
"""Unit tests for the RM power-limit interface (fake RM, no hardware).
|
||||||
|
|
||||||
|
Standalone (no pytest required):
|
||||||
|
|
||||||
|
python tests/test_rm_power.py
|
||||||
|
|
||||||
|
Also works under pytest if available. Ports the test battery from LACT PR
|
||||||
|
#1205 (ilya-zlobintsev/LACT): layout discovery, NVML cross-validation,
|
||||||
|
write minimality, readback verification, and failure restoration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
from nvcurve.hal.rm_power import ( # noqa: E402
|
||||||
|
_CTRL_GPU_GET_ATTACHED_IDS,
|
||||||
|
_CTRL_GPU_GET_ID_INFO_V2,
|
||||||
|
_CTRL_GPU_GET_PCI_INFO,
|
||||||
|
_PWR_GET_CONTROL,
|
||||||
|
_PWR_GET_INFO,
|
||||||
|
_PWR_SET_CONTROL,
|
||||||
|
EXTENDED_LAYOUT,
|
||||||
|
LEGACY_LAYOUT,
|
||||||
|
PciLocation,
|
||||||
|
PowerLimitBounds,
|
||||||
|
RmPowerError,
|
||||||
|
_u32,
|
||||||
|
probe,
|
||||||
|
resolve_gpu_instance,
|
||||||
|
set_limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
PASS = 0
|
||||||
|
FAIL = 0
|
||||||
|
|
||||||
|
|
||||||
|
def check(name: str, cond: bool) -> None:
|
||||||
|
global PASS, FAIL
|
||||||
|
if cond:
|
||||||
|
PASS += 1
|
||||||
|
print(f" PASS {name}")
|
||||||
|
else:
|
||||||
|
FAIL += 1
|
||||||
|
print(f" FAIL {name}")
|
||||||
|
|
||||||
|
|
||||||
|
BOUNDS = PowerLimitBounds(min_mw=250_000, default_mw=300_000, max_mw=325_000)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRm:
|
||||||
|
"""In-memory fake of the RM power-limit client (both wire layouts)."""
|
||||||
|
|
||||||
|
def __init__(self, layout, current: int) -> None:
|
||||||
|
self.layout = layout
|
||||||
|
self.control = bytearray(layout.control_size)
|
||||||
|
self.control[0:8] = bytes([0xFF, 0, 0, 0, 1, 0, 0, 0])
|
||||||
|
self.control[layout.request_at - 4 : layout.request_at] = bytes(
|
||||||
|
[0x67, 0x67, 0, 0]
|
||||||
|
)
|
||||||
|
self.control[layout.request_at : layout.request_at + 4] = current.to_bytes(
|
||||||
|
4, "little"
|
||||||
|
)
|
||||||
|
self.control[layout.client_at] = 0xFE
|
||||||
|
self.reads: list[tuple[int, int]] = []
|
||||||
|
self.writes: list[bytes] = []
|
||||||
|
self.fail_first_write = False
|
||||||
|
self.fail_readback = False
|
||||||
|
self.fail_restore = False
|
||||||
|
|
||||||
|
def query(self, cmd: int, data: bytearray) -> None:
|
||||||
|
if cmd == _PWR_GET_INFO:
|
||||||
|
self.reads.append((cmd, len(data)))
|
||||||
|
if len(data) != self.layout.info_size:
|
||||||
|
raise RmPowerError("Unsupported INFO size")
|
||||||
|
data[0:8] = bytes([0xFF, 0, 0, 0, 1, 0, 0, 0])
|
||||||
|
for index, value in enumerate([250_000, 300_000, 325_000]):
|
||||||
|
offset = self.layout.info_min_at + 4 * index
|
||||||
|
data[offset : offset + 4] = value.to_bytes(4, "little")
|
||||||
|
elif cmd == _PWR_GET_CONTROL:
|
||||||
|
self.reads.append((cmd, len(data)))
|
||||||
|
if len(data) != self.layout.control_size:
|
||||||
|
raise RmPowerError("Unsupported CONTROL size")
|
||||||
|
if data[self.layout.client_at] != 0xFE:
|
||||||
|
raise AssertionError("unexpected client selector on GET")
|
||||||
|
if self.fail_readback and len(self.writes) == 1:
|
||||||
|
raise RmPowerError("readback unavailable")
|
||||||
|
data[:] = self.control
|
||||||
|
elif cmd == _PWR_SET_CONTROL:
|
||||||
|
if len(data) != self.layout.control_size:
|
||||||
|
raise AssertionError("bad SET size")
|
||||||
|
if data[4:8] != (1).to_bytes(4, "little"):
|
||||||
|
raise AssertionError("bad SET mask")
|
||||||
|
if data[self.layout.client_at] != 0xFE:
|
||||||
|
raise AssertionError("bad SET client selector")
|
||||||
|
# Only the request field may differ from the current state.
|
||||||
|
for i, (a, b) in enumerate(zip(data, self.control, strict=True)):
|
||||||
|
if self.layout.request_at <= i < self.layout.request_at + 4:
|
||||||
|
continue
|
||||||
|
if a != b:
|
||||||
|
raise AssertionError(f"SET modified byte {i:#x}")
|
||||||
|
self.writes.append(bytes(data))
|
||||||
|
if self.fail_restore and len(self.writes) > 1:
|
||||||
|
raise RmPowerError("restore unavailable")
|
||||||
|
self.control[:] = data
|
||||||
|
if self.fail_first_write and len(self.writes) == 1:
|
||||||
|
raise RmPowerError("SET failed after modifying hardware")
|
||||||
|
else:
|
||||||
|
raise AssertionError(f"unexpected command {cmd:#x}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_detects_both_layouts_with_gets_without_a_driver_version() -> None:
|
||||||
|
for layout in (EXTENDED_LAYOUT, LEGACY_LAYOUT):
|
||||||
|
rm = FakeRm(layout, 250_000)
|
||||||
|
support = probe(BOUNDS, 250_000, rm.query)
|
||||||
|
check(
|
||||||
|
f"{layout.name}: detected",
|
||||||
|
support.bounds == BOUNDS and support.layout == layout,
|
||||||
|
)
|
||||||
|
expected = (
|
||||||
|
[(_PWR_GET_INFO, 0x924), (_PWR_GET_CONTROL, 0x328)]
|
||||||
|
if layout == EXTENDED_LAYOUT
|
||||||
|
else [
|
||||||
|
(_PWR_GET_INFO, 0x924),
|
||||||
|
(_PWR_GET_INFO, 0x488),
|
||||||
|
(_PWR_GET_CONTROL, 0x188),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
check(f"{layout.name}: GETs only, expected sequence", rm.reads == expected)
|
||||||
|
check(f"{layout.name}: no writes during discovery", rm.writes == [])
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_layout_and_nvml_mismatches_never_write() -> None:
|
||||||
|
calls: list[tuple[int, int]] = []
|
||||||
|
|
||||||
|
def failing(cmd: int, data: bytearray) -> None:
|
||||||
|
calls.append((cmd, len(data)))
|
||||||
|
raise RmPowerError("Unsupported payload")
|
||||||
|
|
||||||
|
try:
|
||||||
|
probe(BOUNDS, 250_000, failing)
|
||||||
|
check("unknown layout rejected", False)
|
||||||
|
except RmPowerError:
|
||||||
|
check("unknown layout rejected", True)
|
||||||
|
check(
|
||||||
|
"unknown layout: only GETs attempted",
|
||||||
|
calls == [(_PWR_GET_INFO, 0x924), (_PWR_GET_INFO, 0x488)],
|
||||||
|
)
|
||||||
|
|
||||||
|
for layout in (EXTENDED_LAYOUT, LEGACY_LAYOUT):
|
||||||
|
rm = FakeRm(layout, 250_000)
|
||||||
|
try:
|
||||||
|
probe(BOUNDS, 300_000, rm.query)
|
||||||
|
check(f"{layout.name}: current mismatch rejected", False)
|
||||||
|
except RmPowerError:
|
||||||
|
check(f"{layout.name}: current mismatch rejected", True)
|
||||||
|
other_bounds = PowerLimitBounds(
|
||||||
|
min_mw=BOUNDS.min_mw, default_mw=BOUNDS.default_mw, max_mw=350_000
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
probe(other_bounds, 250_000, rm.query)
|
||||||
|
check(f"{layout.name}: bounds mismatch rejected", False)
|
||||||
|
except RmPowerError:
|
||||||
|
check(f"{layout.name}: bounds mismatch rejected", True)
|
||||||
|
check(f"{layout.name}: no writes on mismatch", rm.writes == [])
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_unrecognized_headers_masks_and_client_values() -> None:
|
||||||
|
for layout in (EXTENDED_LAYOUT, LEGACY_LAYOUT):
|
||||||
|
for at, value in [(0, 0), (4, 3), (layout.client_at, 0xF8)]:
|
||||||
|
rm = FakeRm(layout, 250_000)
|
||||||
|
rm.control[at] = value
|
||||||
|
try:
|
||||||
|
probe(BOUNDS, 250_000, rm.query)
|
||||||
|
check(f"{layout.name}: bad header/client rejected", False)
|
||||||
|
except RmPowerError:
|
||||||
|
check(f"{layout.name}: bad header/client rejected", True)
|
||||||
|
check(f"{layout.name}: no writes on bad header", rm.writes == [])
|
||||||
|
for current in (0, 0xFFFFFFFF):
|
||||||
|
rm = FakeRm(layout, current)
|
||||||
|
try:
|
||||||
|
probe(BOUNDS, current, rm.query)
|
||||||
|
check(f"{layout.name}: empty request rejected", False)
|
||||||
|
except RmPowerError:
|
||||||
|
check(f"{layout.name}: empty request rejected", True)
|
||||||
|
# The extended layout has additional mask words. Accepting only its low
|
||||||
|
# word would allow an unexpected client to be included in a later SET.
|
||||||
|
rm = FakeRm(EXTENDED_LAYOUT, 250_000)
|
||||||
|
rm.control[8] = 1
|
||||||
|
try:
|
||||||
|
probe(BOUNDS, 250_000, rm.query)
|
||||||
|
check("extended: nonzero mask word rejected", False)
|
||||||
|
except RmPowerError:
|
||||||
|
check("extended: nonzero mask word rejected", True)
|
||||||
|
check("extended: no writes on mask violation", rm.writes == [])
|
||||||
|
|
||||||
|
|
||||||
|
def test_changes_only_fe_request_and_keeps_vbios_maximum() -> None:
|
||||||
|
for layout in (EXTENDED_LAYOUT, LEGACY_LAYOUT):
|
||||||
|
rm = FakeRm(layout, 250_000)
|
||||||
|
support = probe(BOUNDS, 250_000, rm.query)
|
||||||
|
for cap in (150_000, 30_000, 250_000):
|
||||||
|
set_limit(cap, support, rm.query)
|
||||||
|
check(
|
||||||
|
f"{layout.name}: set {cap} mW",
|
||||||
|
_u32(rm.control, layout.request_at) == cap,
|
||||||
|
)
|
||||||
|
writes = len(rm.writes)
|
||||||
|
for cap in (0, 29_999, 325_001, 350_000, 0xFFFFFFFF):
|
||||||
|
try:
|
||||||
|
set_limit(cap, support, rm.query)
|
||||||
|
check(f"{layout.name}: out-of-range {cap} rejected", False)
|
||||||
|
except RmPowerError:
|
||||||
|
check(f"{layout.name}: out-of-range {cap} rejected", True)
|
||||||
|
check(
|
||||||
|
f"{layout.name}: no writes for out-of-range caps",
|
||||||
|
len(rm.writes) == writes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_restores_previous_below_minimum_request_after_set_or_readback_failure() -> (
|
||||||
|
None
|
||||||
|
):
|
||||||
|
for layout in (EXTENDED_LAYOUT, LEGACY_LAYOUT):
|
||||||
|
for fail_set in (False, True):
|
||||||
|
rm = FakeRm(layout, 100_000)
|
||||||
|
original = bytes(rm.control)
|
||||||
|
rm.fail_first_write = fail_set
|
||||||
|
rm.fail_readback = not fail_set
|
||||||
|
support = probe(BOUNDS, 100_000, rm.query)
|
||||||
|
try:
|
||||||
|
set_limit(150_000, support, rm.query)
|
||||||
|
check(f"{layout.name}: failure reported", False)
|
||||||
|
except RmPowerError:
|
||||||
|
check(f"{layout.name}: failure reported", True)
|
||||||
|
check(f"{layout.name}: restore issued", len(rm.writes) == 2)
|
||||||
|
check(
|
||||||
|
f"{layout.name}: previous request restored",
|
||||||
|
bytes(rm.control) == original,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reports_restore_failure_and_rejects_wrong_client_before_writing() -> None:
|
||||||
|
for layout in (EXTENDED_LAYOUT, LEGACY_LAYOUT):
|
||||||
|
rm = FakeRm(layout, 100_000)
|
||||||
|
rm.fail_first_write = True
|
||||||
|
rm.fail_restore = True
|
||||||
|
support = probe(BOUNDS, 100_000, rm.query)
|
||||||
|
try:
|
||||||
|
set_limit(150_000, support, rm.query)
|
||||||
|
check(f"{layout.name}: restore failure reported", False)
|
||||||
|
except RmPowerError as exc:
|
||||||
|
check(
|
||||||
|
f"{layout.name}: restore failure reported",
|
||||||
|
"restoration also failed" in str(exc),
|
||||||
|
)
|
||||||
|
rm = FakeRm(layout, 250_000)
|
||||||
|
rm.control[layout.client_at] = 0xF8
|
||||||
|
try:
|
||||||
|
set_limit(150_000, support, rm.query)
|
||||||
|
check(f"{layout.name}: wrong client rejected", False)
|
||||||
|
except RmPowerError:
|
||||||
|
check(f"{layout.name}: wrong client rejected", True)
|
||||||
|
check(f"{layout.name}: no writes for wrong client", rm.writes == [])
|
||||||
|
|
||||||
|
|
||||||
|
# ── PCI identity → RM instance resolution ────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolves_pci_identity_when_minor_and_rm_orders_differ() -> None:
|
||||||
|
# This host has Ada at minor 5/RM 4 and the 5090 at minor 4/RM 5.
|
||||||
|
# IDs are opaque and enumeration order must not select the device.
|
||||||
|
pci = PciLocation(domain=0, bus=0x0D, dev=0, func=0)
|
||||||
|
instances = resolve_gpu_instance(pci, lambda cmd, data: _fake_root(cmd, data))
|
||||||
|
check("resolves by PCI identity", instances == (5, 2))
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_root(cmd: int, data: bytearray) -> None:
|
||||||
|
if cmd == _CTRL_GPU_GET_ATTACHED_IDS:
|
||||||
|
data[0:4] = (0x2E00).to_bytes(4, "little")
|
||||||
|
data[4:8] = (0x0D00).to_bytes(4, "little")
|
||||||
|
elif cmd == _CTRL_GPU_GET_PCI_INFO:
|
||||||
|
gpu_id = _u32(data, 0)
|
||||||
|
bus = 0x2E if gpu_id == 0x2E00 else 0x0D
|
||||||
|
data[8:10] = bus.to_bytes(2, "little")
|
||||||
|
elif cmd == _CTRL_GPU_GET_ID_INFO_V2:
|
||||||
|
if _u32(data, 0) != 0x0D00:
|
||||||
|
raise AssertionError("unexpected gpu id in ID_INFO_V2")
|
||||||
|
data[8:12] = (5).to_bytes(4, "little")
|
||||||
|
data[12:16] = (2).to_bytes(4, "little")
|
||||||
|
else:
|
||||||
|
raise AssertionError(f"unexpected command {cmd:#x}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_does_not_fall_back_to_another_gpu_when_pci_is_missing() -> None:
|
||||||
|
pci = PciLocation(domain=1, bus=0x0D, dev=0, func=0)
|
||||||
|
|
||||||
|
def query(cmd: int, data: bytearray) -> None:
|
||||||
|
if cmd == _CTRL_GPU_GET_ATTACHED_IDS:
|
||||||
|
data[0:4] = (0x0D00).to_bytes(4, "little")
|
||||||
|
elif cmd == _CTRL_GPU_GET_PCI_INFO:
|
||||||
|
data[8:10] = (0x0D).to_bytes(2, "little")
|
||||||
|
else:
|
||||||
|
raise AssertionError("must not allocate a GPU from another PCI domain")
|
||||||
|
|
||||||
|
try:
|
||||||
|
resolve_gpu_instance(pci, query)
|
||||||
|
check("foreign PCI domain rejected", False)
|
||||||
|
except RmPowerError:
|
||||||
|
check("foreign PCI domain rejected", True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_nonzero_pci_function() -> None:
|
||||||
|
pci = PciLocation(domain=0, bus=0x0D, dev=0, func=1)
|
||||||
|
try:
|
||||||
|
resolve_gpu_instance(pci, lambda cmd, data: None)
|
||||||
|
check("nonzero function rejected", False)
|
||||||
|
except RmPowerError:
|
||||||
|
check("nonzero function rejected", True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_propagates_rm_query_failure() -> None:
|
||||||
|
pci = PciLocation(domain=0, bus=0x0D, dev=0, func=0)
|
||||||
|
try:
|
||||||
|
resolve_gpu_instance(
|
||||||
|
pci, lambda cmd, data: (_ for _ in ()).throw(RmPowerError("RM unavailable"))
|
||||||
|
)
|
||||||
|
check("RM query failure propagated", False)
|
||||||
|
except RmPowerError as exc:
|
||||||
|
check("RM query failure propagated", "RM unavailable" in str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
tests = [
|
||||||
|
test_detects_both_layouts_with_gets_without_a_driver_version,
|
||||||
|
test_unknown_layout_and_nvml_mismatches_never_write,
|
||||||
|
test_rejects_unrecognized_headers_masks_and_client_values,
|
||||||
|
test_changes_only_fe_request_and_keeps_vbios_maximum,
|
||||||
|
test_restores_previous_below_minimum_request_after_set_or_readback_failure,
|
||||||
|
test_reports_restore_failure_and_rejects_wrong_client_before_writing,
|
||||||
|
test_resolves_pci_identity_when_minor_and_rm_orders_differ,
|
||||||
|
test_does_not_fall_back_to_another_gpu_when_pci_is_missing,
|
||||||
|
test_rejects_nonzero_pci_function,
|
||||||
|
test_propagates_rm_query_failure,
|
||||||
|
]
|
||||||
|
for t in tests:
|
||||||
|
print(f"== {t.__name__} ==")
|
||||||
|
t()
|
||||||
|
print(f"\n{PASS} passed, {FAIL} failed")
|
||||||
|
return 1 if FAIL else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
"""Security regression tests for nvcurve.
|
||||||
|
|
||||||
|
Standalone (no pytest required):
|
||||||
|
|
||||||
|
python tests/test_security.py
|
||||||
|
|
||||||
|
Also works under pytest if available. Covers the security-critical logic:
|
||||||
|
SPA path containment, snapshot restore containment, login lockout client-IP
|
||||||
|
derivation, TLS scheme detection, daemon socket hardening, and the
|
||||||
|
server-enforced safety cap.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import builtins
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
from nvcurve import (
|
||||||
|
daemon, # noqa: E402
|
||||||
|
server, # noqa: E402
|
||||||
|
)
|
||||||
|
from nvcurve.cli import _discover_server_url # noqa: E402
|
||||||
|
from nvcurve.config import ( # noqa: E402
|
||||||
|
Config,
|
||||||
|
normalize_trusted_proxies,
|
||||||
|
tls_enabled,
|
||||||
|
)
|
||||||
|
from nvcurve.hal.snapshot import restore as snapshot_restore # noqa: E402
|
||||||
|
|
||||||
|
PASS = 0
|
||||||
|
FAIL = 0
|
||||||
|
|
||||||
|
|
||||||
|
def check(name: str, cond: bool) -> None:
|
||||||
|
global PASS, FAIL
|
||||||
|
if cond:
|
||||||
|
PASS += 1
|
||||||
|
print(f" PASS {name}")
|
||||||
|
else:
|
||||||
|
FAIL += 1
|
||||||
|
print(f" FAIL {name}")
|
||||||
|
|
||||||
|
|
||||||
|
def _encoded_traversal(target: str = "/etc/hostname") -> str:
|
||||||
|
"""Build an encoded '..'-based traversal path deep enough to escape any dist dir."""
|
||||||
|
dist = os.path.abspath(server._dist_dir)
|
||||||
|
depth = len(dist.rstrip("/").split("/"))
|
||||||
|
enc = lambda s: s.replace("/", "%2f") # noqa: E731
|
||||||
|
return "/" + enc("../*" + str(depth + 2) + target)
|
||||||
|
|
||||||
|
|
||||||
|
def test_spa_path_containment() -> None:
|
||||||
|
"""The SPA catch-all must not serve files outside the dist directory."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
client = TestClient(server.app)
|
||||||
|
r = client.get(_encoded_traversal())
|
||||||
|
check("SPA traversal -> 404", r.status_code == 404)
|
||||||
|
r = client.get(_encoded_traversal("/etc/passwd"))
|
||||||
|
check("SPA traversal /etc/passwd -> 404", r.status_code == 404)
|
||||||
|
# Normal static files must still be served.
|
||||||
|
r = client.get("/index.html")
|
||||||
|
check("SPA /index.html -> 200", r.status_code == 200)
|
||||||
|
r = client.get("/api/nonexistent")
|
||||||
|
check("unknown /api/ path -> 404", r.status_code == 404)
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_restore_containment() -> None:
|
||||||
|
"""Snapshot restore must only read files inside the snapshot dir."""
|
||||||
|
tmp = tempfile.mkdtemp()
|
||||||
|
snap_dir = os.path.join(tmp, "snaps")
|
||||||
|
os.makedirs(snap_dir)
|
||||||
|
outside = os.path.join(tmp, "evil.bin")
|
||||||
|
with open(outside, "wb") as f:
|
||||||
|
f.write(b"\x00" * 9248)
|
||||||
|
|
||||||
|
check(
|
||||||
|
"restore(outside file) rejected",
|
||||||
|
snapshot_restore(None, snap_dir, outside) is False,
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"restore(nonexistent) rejected",
|
||||||
|
snapshot_restore(None, snap_dir, "/etc/hostname") is False,
|
||||||
|
)
|
||||||
|
link = os.path.join(snap_dir, "link.bin")
|
||||||
|
os.symlink(outside, link)
|
||||||
|
check(
|
||||||
|
"restore(symlink escape) rejected",
|
||||||
|
snapshot_restore(None, snap_dir, link) is False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_safety_cap_not_client_overridable() -> None:
|
||||||
|
"""The API must not accept a per-request safety cap override."""
|
||||||
|
check(
|
||||||
|
"WriteRequest has no max_delta_khz field",
|
||||||
|
"max_delta_khz" not in server.WriteRequest.model_fields,
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"GlobalOffsetRequest has no max_delta_khz field",
|
||||||
|
"max_delta_khz" not in server.GlobalOffsetRequest.model_fields,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_ip_derivation() -> None:
|
||||||
|
"""X-Forwarded-For is only honoured for configured trusted proxies."""
|
||||||
|
|
||||||
|
class FakeReq:
|
||||||
|
def __init__(self, peer: str, headers: dict):
|
||||||
|
self.client = type("C", (), {"host": peer})()
|
||||||
|
self.headers = headers
|
||||||
|
|
||||||
|
check(
|
||||||
|
"trusted proxy -> XFF used",
|
||||||
|
server._client_ip(
|
||||||
|
FakeReq("127.0.0.1", {"x-forwarded-for": "9.9.9.9"}), ["127.0.0.1"]
|
||||||
|
)
|
||||||
|
== "9.9.9.9",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"untrusted peer -> XFF ignored",
|
||||||
|
server._client_ip(
|
||||||
|
FakeReq("8.8.8.8", {"x-forwarded-for": "9.9.9.9"}), ["127.0.0.1"]
|
||||||
|
)
|
||||||
|
== "8.8.8.8",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"rightmost untrusted hop",
|
||||||
|
server._client_ip(
|
||||||
|
FakeReq("127.0.0.1", {"x-forwarded-for": "127.0.0.1, 9.9.9.9"}),
|
||||||
|
["127.0.0.1"],
|
||||||
|
)
|
||||||
|
== "9.9.9.9",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"no XFF -> peer",
|
||||||
|
server._client_ip(FakeReq("127.0.0.1", {}), ["127.0.0.1"]) == "127.0.0.1",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"all-trusted chain -> peer",
|
||||||
|
server._client_ip(
|
||||||
|
FakeReq("127.0.0.1", {"x-forwarded-for": "10.0.0.1, 10.0.0.2"}),
|
||||||
|
["127.0.0.1", "10.0.0.1", "10.0.0.2"],
|
||||||
|
)
|
||||||
|
== "127.0.0.1",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_trusted_proxies_normalization() -> None:
|
||||||
|
"""String values must be normalized to lists (no substring matching)."""
|
||||||
|
check("list passthrough", normalize_trusted_proxies(["1.2.3.4"]) == ["1.2.3.4"])
|
||||||
|
check(
|
||||||
|
"comma string split",
|
||||||
|
normalize_trusted_proxies("1.2.3.4, 5.6.7.8") == ["1.2.3.4", "5.6.7.8"],
|
||||||
|
)
|
||||||
|
check("None -> []", normalize_trusted_proxies(None) == [])
|
||||||
|
check("junk -> []", normalize_trusted_proxies(42) == [])
|
||||||
|
# The original bug: substring membership. After normalization, "127.0.0.1"
|
||||||
|
# must NOT be trusted when only "127.0.0.10" is listed.
|
||||||
|
trusted = normalize_trusted_proxies(["127.0.0.10"])
|
||||||
|
check("no substring trust", "127.0.0.1" not in trusted)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tls_scheme_detection() -> None:
|
||||||
|
"""_discover_server_url must pick https when TLS is configured."""
|
||||||
|
from nvcurve import cli as cli_mod
|
||||||
|
|
||||||
|
# Hermetic: hide any live server's runtime info file and any existing
|
||||||
|
# persistent config so the defaults level of the priority chain is
|
||||||
|
# exercised.
|
||||||
|
real_info_file = cli_mod._SERVER_INFO_FILE
|
||||||
|
real_persistent_cfg = cli_mod._PERSISTENT_CONFIG_FILE
|
||||||
|
hidden = tempfile.mkdtemp()
|
||||||
|
cli_mod._SERVER_INFO_FILE = os.path.join(hidden, "nvcurve.json")
|
||||||
|
cli_mod._PERSISTENT_CONFIG_FILE = os.path.join(hidden, "config.json")
|
||||||
|
try:
|
||||||
|
cfg = Config(
|
||||||
|
host="10.0.0.5", port=9000, ssl_certfile="/x/c.pem", ssl_keyfile="/x/k.pem"
|
||||||
|
)
|
||||||
|
check("tls_enabled true", tls_enabled(cfg) is True)
|
||||||
|
check("https url", _discover_server_url(cfg) == "https://10.0.0.5:9000")
|
||||||
|
cfg2 = Config(host="10.0.0.5", port=9000)
|
||||||
|
check("http url", _discover_server_url(cfg2) == "http://10.0.0.5:9000")
|
||||||
|
finally:
|
||||||
|
cli_mod._SERVER_INFO_FILE = real_info_file
|
||||||
|
cli_mod._PERSISTENT_CONFIG_FILE = real_persistent_cfg
|
||||||
|
|
||||||
|
|
||||||
|
def test_daemon_ignores_caller_host_port() -> None:
|
||||||
|
"""serve_start must bind the configured host/port, never the caller's."""
|
||||||
|
daemon._cfg = Config(host="10.1.1.1", port=9999)
|
||||||
|
captured: dict = {}
|
||||||
|
|
||||||
|
class FakePopen:
|
||||||
|
def __init__(self, cmd, **kw):
|
||||||
|
captured["cmd"] = cmd
|
||||||
|
self.pid = 4242
|
||||||
|
|
||||||
|
def poll(self):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def terminate(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def wait(self, *a):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
real_open = builtins.open
|
||||||
|
|
||||||
|
def fake_open(path, *a, **kw):
|
||||||
|
if str(path).endswith("nvcurve-server.log"):
|
||||||
|
return io.StringIO()
|
||||||
|
return real_open(path, *a, **kw)
|
||||||
|
|
||||||
|
orig_popen = daemon.subprocess.Popen
|
||||||
|
daemon.subprocess.Popen = FakePopen
|
||||||
|
builtins.open = fake_open
|
||||||
|
try:
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
resp = loop.run_until_complete(
|
||||||
|
# "0.0.0.0" is a test payload proving the daemon ignores caller
|
||||||
|
# host/port — no socket is bound here.
|
||||||
|
daemon._dispatch({"cmd": "serve_start", "host": "0.0.0.0", "port": 12345}) # noqa: S104
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
builtins.open = real_open
|
||||||
|
daemon.subprocess.Popen = orig_popen
|
||||||
|
|
||||||
|
cmd = " ".join(captured.get("cmd", []))
|
||||||
|
check("config host/port used", "10.1.1.1" in cmd and "9999" in cmd)
|
||||||
|
check("caller host/port ignored", "0.0.0.0" not in cmd and "12345" not in cmd) # noqa: S104
|
||||||
|
check(
|
||||||
|
"response reports configured values",
|
||||||
|
resp.get("ok") is True
|
||||||
|
and resp.get("host") == "10.1.1.1"
|
||||||
|
and resp.get("port") == 9999,
|
||||||
|
)
|
||||||
|
check("response includes tls flag", "tls" in resp)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
tests = [
|
||||||
|
test_spa_path_containment,
|
||||||
|
test_snapshot_restore_containment,
|
||||||
|
test_safety_cap_not_client_overridable,
|
||||||
|
test_client_ip_derivation,
|
||||||
|
test_trusted_proxies_normalization,
|
||||||
|
test_tls_scheme_detection,
|
||||||
|
test_daemon_ignores_caller_host_port,
|
||||||
|
]
|
||||||
|
for t in tests:
|
||||||
|
print(f"== {t.__name__} ==")
|
||||||
|
t()
|
||||||
|
print(f"\n{PASS} passed, {FAIL} failed")
|
||||||
|
return 1 if FAIL else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -154,6 +154,22 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hatchling"
|
||||||
|
version = "1.32.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pathspec" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "tomlkit" },
|
||||||
|
{ name = "trove-classifiers" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/69/08/33331757185504aae48b8d9bd78cec03a76e3aecfb52e549d05a2347c0dd/hatchling-1.32.0.tar.gz", hash = "sha256:0bdbde4a52b06c37e3eca395f85a762bf0ef06fe374fd8ae429dc6be10230f5f", size = 57783, upload-time = "2026-08-11T05:03:44.114Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/84/1798b6d85ecde0e31546004efd25c5de1b1f49250644a60cce460e12593a/hatchling-1.32.0-py3-none-any.whl", hash = "sha256:0e17c9c3b9aa7c625acc8d0f5b622f107d5049af9ecf5ada4de1aada5be7cdbc", size = 78435, upload-time = "2026-08-11T05:03:42.644Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httpcore"
|
name = "httpcore"
|
||||||
version = "1.0.9"
|
version = "1.0.9"
|
||||||
@@ -233,6 +249,11 @@ dependencies = [
|
|||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "hatchling" },
|
||||||
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "bcrypt", specifier = ">=4.0" },
|
{ name = "bcrypt", specifier = ">=4.0" },
|
||||||
@@ -243,6 +264,9 @@ requires-dist = [
|
|||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30" },
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
dev = [{ name = "hatchling" }]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nvidia-ml-py"
|
name = "nvidia-ml-py"
|
||||||
version = "13.590.48"
|
version = "13.590.48"
|
||||||
@@ -252,6 +276,33 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/fd/72/fb2af0d259a651affdce65fd6a495f0e07a685a0136baf585c5065204ee7/nvidia_ml_py-13.590.48-py3-none-any.whl", hash = "sha256:fd43d30ee9cd0b7940f5f9f9220b68d42722975e3992b6c21d14144c48760e43", size = 50680, upload-time = "2026-01-22T01:14:55.281Z" },
|
{ url = "https://files.pythonhosted.org/packages/fd/72/fb2af0d259a651affdce65fd6a495f0e07a685a0136baf585c5065204ee7/nvidia_ml_py-13.590.48-py3-none-any.whl", hash = "sha256:fd43d30ee9cd0b7940f5f9f9220b68d42722975e3992b6c21d14144c48760e43", size = 50680, upload-time = "2026-01-22T01:14:55.281Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "26.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pathspec"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pydantic"
|
name = "pydantic"
|
||||||
version = "2.12.5"
|
version = "2.12.5"
|
||||||
@@ -406,6 +457,24 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
|
{ url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tomlkit"
|
||||||
|
version = "0.15.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/94/96/e07752635b98536177fa1f37671c8f3cdde2e724c6bcf6034b2cfb571565/tomlkit-0.15.1.tar.gz", hash = "sha256:e25bbf38843005246210a12982776f27f99cb9be67160e14434d0c0d21ee1e97", size = 180129, upload-time = "2026-07-17T01:48:04.562Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl", hash = "sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304", size = 49449, upload-time = "2026-07-17T01:48:05.728Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "trove-classifiers"
|
||||||
|
version = "2026.6.1.19"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c2/e3/7ca82ee24c82d344584abd5b8637b3bd056f2900226e8d82fc22f1184b92/trove_classifiers-2026.6.1.19.tar.gz", hash = "sha256:c5132b4b61a829d11cfbd2d72e97f20a45ed6edb95e45c5efdeb5e00836b2745", size = 17059, upload-time = "2026-06-01T19:41:34.649Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/a4/81502f486f01db95bc8320646a8a12511f5e556cb63d5e224d91816605c4/trove_classifiers-2026.6.1.19-py3-none-any.whl", hash = "sha256:ab4c4ec93cc4a4e7815fa759906e05e6bb3f2fbd92ea0f897288c6a43efd15b3", size = 14211, upload-time = "2026-06-01T19:41:33.434Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typing-extensions"
|
name = "typing-extensions"
|
||||||
version = "4.15.0"
|
version = "4.15.0"
|
||||||
|
|||||||
Reference in new issue
Block a user