Преглед на файлове

initial commit: amd-gpu-temp-control

- amd-gpu-temp-control.js — sysfs temperature monitor + upp TDP control
- amd-gpu-temp-control.service — systemd unit (hardened, ProtectSystem)
- install.sh — portable installer (install/uninstall)
- README.md — documentation
Глухов Кирилл Викторович преди 1 месец
ревизия
e28a78eee5
променени са 5 файла, в които са добавени 439 реда и са изтрити 0 реда
  1. 6 0
      .gitignore
  2. 95 0
      README.md
  3. 284 0
      amd-gpu-temp-control.js
  4. 17 0
      amd-gpu-temp-control.service
  5. 37 0
      install.sh

+ 6 - 0
.gitignore

@@ -0,0 +1,6 @@
+*.log
+*.pid
+*.swp
+*~
+.DS_Store
+__pycache__/

+ 95 - 0
README.md

@@ -0,0 +1,95 @@
+# amd-gpu-temp-control
+
+Monitors AMD Radeon GPU temperatures via sysfs and dynamically adjusts TDP limits using [upp](https://github.com/sibradzic/upp) to keep temperatures within a configured range.
+
+## Supported GPUs
+
+AMD Radeon discrete GPUs with PowerPlay table support:
+
+- Polaris
+- Vega
+- Radeon VII
+- Navi 10, 12, 14
+- Navi 21, 22, 23
+- Navi 3x (experimental)
+
+**Not supported:** AMD APU iGPUs (different PowerPlay control methods).
+
+## How it works
+
+1. Reads temperature from all `temp*_input` sensors under `/sys/class/drm/card{N}/device/hwmon/`
+2. Compares against configured min/max temperature thresholds
+3. When temperature exceeds the range, linearly scales TDP down via `upp` PowerPlay table modifications
+4. When temperature drops below the range, restores TDP to maximum
+5. Changes are applied in configured step increments to avoid abrupt power shifts
+
+## Requirements
+
+- **Node.js** (v14+, for `fs`/`child_process` only — no npm packages)
+- **upp** — Radeon PowerPlay table tool (`pipx install upp` or build from source)
+- **root** — required for writing PowerPlay tables
+
+## Install
+
+```bash
+sudo ./install.sh
+```
+
+Copies the script to `/usr/local/bin/amd-gpu-temp-control` and installs the systemd service.
+
+## Uninstall
+
+```bash
+sudo ./install.sh --uninstall
+```
+
+Removes the binary and service unit, stops the service.
+
+## Configuration
+
+Default config path: `/etc/amd-gpu-temp-control.json`
+
+```json
+{
+  "minTemp": 70,
+  "maxTemp": 80,
+  "minTdpPercent": 10,
+  "maxTdpPercent": 100,
+  "tdpStepPercent": 10,
+  "checkIntervalMs": 2000,
+  "cmdTimeoutMs": 5000,
+  "uppPath": "upp"
+}
+```
+
+| Parameter          | Default | Description                                              |
+|--------------------|---------|----------------------------------------------------------|
+| `minTemp`          | 70      | Lower temperature threshold (°C)                         |
+| `maxTemp`          | 80      | Upper temperature threshold (°C)                         |
+| `minTdpPercent`    | 10      | Minimum TDP percentage when throttling                   |
+| `maxTdpPercent`    | 100     | Maximum TDP percentage (full power)                      |
+| `tdpStepPercent`   | 10      | Maximum TDP change per cycle (%)                         |
+| `checkIntervalMs`  | 2000    | Temperature read interval (ms)                           |
+| `cmdTimeoutMs`     | 5000    | Timeout for `upp` command (ms)                           |
+| `uppPath`          | `upp`   | Path to the `upp` binary                                 |
+
+## Usage
+
+```
+node amd-gpu-temp-control.js [options]
+
+  --config, -c <path>   Config file path
+  --help, -h            Show help
+```
+
+## Service management
+
+```bash
+systemctl status amd-gpu-temp-control
+journalctl -u amd-gpu-temp-control -f
+systemctl restart amd-gpu-temp-control
+```
+
+## License
+
+MIT

+ 284 - 0
amd-gpu-temp-control.js

@@ -0,0 +1,284 @@
+#!/usr/bin/env node
+
+const { execSync } = require('child_process');
+const fs = require('fs');
+const path = require('path');
+
+const DEFAULT_CONFIG_PATH = '/etc/amd-gpu-temp-control.json';
+const DEFAULT_MIN_TEMP = 70;
+const DEFAULT_MAX_TEMP = 80;
+const DEFAULT_MIN_TDP_PERCENT = 10;
+const DEFAULT_MAX_TDP_PERCENT = 100;
+const DEFAULT_TDP_STEP_PERCENT = 10;
+const DEFAULT_CHECK_INTERVAL_MS = 2000;
+const DEFAULT_CMD_TIMEOUT_MS = 5000;
+const DEFAULT_UPP_PATH = 'upp';
+
+function loadConfig(configPath) {
+  if (!fs.existsSync(configPath)) {
+    return null;
+  }
+  const raw = fs.readFileSync(configPath, 'utf-8');
+  return JSON.parse(raw);
+}
+
+function mergeConfig(config) {
+  return {
+    minTemp: config.minTemp ?? DEFAULT_MIN_TEMP,
+    maxTemp: config.maxTemp ?? DEFAULT_MAX_TEMP,
+    minTdpPercent: config.minTdpPercent ?? DEFAULT_MIN_TDP_PERCENT,
+    maxTdpPercent: config.maxTdpPercent ?? DEFAULT_MAX_TDP_PERCENT,
+    tdpStepPercent: config.tdpStepPercent ?? DEFAULT_TDP_STEP_PERCENT,
+    checkIntervalMs: config.checkIntervalMs ?? DEFAULT_CHECK_INTERVAL_MS,
+    cmdTimeoutMs: config.cmdTimeoutMs ?? DEFAULT_CMD_TIMEOUT_MS,
+    uppPath: config.uppPath ?? DEFAULT_UPP_PATH,
+  };
+}
+
+function getCardIndices() {
+  const entries = fs.readdirSync('/sys/class/drm');
+  const indices = [];
+  for (const name of entries) {
+    const match = name.match(/^card(\d+)$/);
+    if (match) {
+      indices.push(parseInt(match[1], 10));
+    }
+  }
+  return indices.length > 0 ? indices : [0];
+}
+
+function findHwmonDirs(cardNumber) {
+  const hwmonDirs = [];
+  const baseDir = `/sys/class/drm/card${cardNumber}/device/hwmon`;
+
+  if (fs.existsSync(baseDir)) {
+    for (const entry of fs.readdirSync(baseDir)) {
+      if (entry.startsWith('hwmon')) {
+        hwmonDirs.push(path.join(baseDir, entry));
+      }
+    }
+  }
+
+  const altBase = `/sys/class/drm/card${cardNumber}/device`;
+  if (fs.existsSync(altBase)) {
+    for (const entry of fs.readdirSync(altBase)) {
+      if (entry.startsWith('hwmon')) {
+        const dirPath = path.join(altBase, entry);
+        if (!hwmonDirs.includes(dirPath)) {
+          hwmonDirs.push(dirPath);
+        }
+      }
+    }
+  }
+
+  return hwmonDirs;
+}
+
+function getTemperature(cardNumber) {
+  const hwmonDirs = findHwmonDirs(cardNumber);
+  if (hwmonDirs.length === 0) return null;
+
+  let maxTemp = null;
+
+  for (const hwmonDir of hwmonDirs) {
+    try {
+      const entries = fs.readdirSync(hwmonDir);
+      for (const entry of entries) {
+        const match = entry.match(/^temp\d+_input$/);
+        if (match) {
+          const raw = fs.readFileSync(path.join(hwmonDir, entry), 'utf-8').trim();
+          const value = parseInt(raw, 10);
+          if (!isNaN(value)) {
+            const tempC = value / 1000;
+            if (maxTemp === null || tempC > maxTemp) {
+              maxTemp = tempC;
+            }
+          }
+        }
+      }
+    } catch {
+    }
+  }
+
+  return maxTemp;
+}
+
+function getDeviceName(cardNumber) {
+  try {
+    const namePath = `/sys/class/drm/card${cardNumber}/device/name`;
+    if (fs.existsSync(namePath)) {
+      return fs.readFileSync(namePath, 'utf-8').trim();
+    }
+  } catch { /* ignore */ }
+
+  try {
+    const slotPath = `/sys/class/drm/card${cardNumber}/device/uevent`;
+    if (fs.existsSync(slotPath)) {
+      const lines = fs.readFileSync(slotPath, 'utf-8').trim().split('\n');
+      for (const line of lines) {
+        const match = line.match(/^PCI_SLOT_NAME=(.*)$/);
+        if (match) {
+          const slot = match[1].trim();
+          try {
+            const out = execSync(`lspci -s ${slot} 2>/dev/null`, { encoding: 'utf-8' }).trim();
+            if (out) return out.split(/\s{3,}/).pop().trim();
+          } catch { /* ignore */ }
+        }
+      }
+    }
+  } catch { /* ignore */ }
+
+  return `card${cardNumber}`;
+}
+
+function getCardPath(cardNumber) {
+  return `/sys/class/drm/card${cardNumber}/device/pp_table`;
+}
+
+function setTdpForCard(cardNumber, power, config) {
+  const cardPath = getCardPath(cardNumber);
+  const cmd = `${config.uppPath} -p ${cardPath} set --write ` +
+    `SmallPowerLimit1=${power} ` +
+    `SmallPowerLimit2=${power} ` +
+    `BoostPowerLimit=${power} ` +
+    `smcPPTable/SocketPowerLimitAc0=${power} ` +
+    `smcPPTable/SocketPowerLimitDc=${power}`;
+  execSync(cmd, { encoding: 'utf-8', stdio: 'pipe', timeout: config.cmdTimeoutMs });
+}
+
+function parseArgs(argv) {
+  let configPath = null;
+
+  for (let i = 2; i < argv.length; i++) {
+    switch (argv[i]) {
+      case '--config':
+      case '-c':
+        configPath = argv[++i];
+        break;
+      case '--help':
+      case '-h':
+        console.log('Usage: node amd-gpu-temp-control.js [options]');
+        console.log('');
+        console.log('Options:');
+        console.log('  --config, -c <path>   Config file path (default: /etc/amd-gpu-temp-control.json)');
+        console.log('  --help, -h            Show this help message');
+        process.exit(0);
+        break;
+    }
+  }
+
+  return configPath || DEFAULT_CONFIG_PATH;
+}
+
+function calculateTargetTdpPercent(temp, minTemp, maxTemp, minTdpPercent, maxTdpPercent) {
+  if (temp <= minTemp) return maxTdpPercent;
+  if (temp >= maxTemp) return minTdpPercent;
+
+  const ratio = (temp - minTemp) / (maxTemp - minTemp);
+  return Math.round(maxTdpPercent - ratio * (maxTdpPercent - minTdpPercent));
+}
+
+function wattsFromPercent(percent, maxTdp) {
+  return Math.round((percent / 100) * maxTdp);
+}
+
+function log(message) {
+  const timestamp = new Date().toISOString();
+  console.log(`[${timestamp}] ${message}`);
+}
+
+async function main() {
+  const configPath = parseArgs(process.argv);
+  const rawConfig = loadConfig(configPath);
+  const config = mergeConfig(rawConfig || {});
+
+  const cardIndices = getCardIndices();
+
+  log('AMD GPU Temperature Control');
+  log(`Temperature range: ${config.minTemp}°C - ${config.maxTemp}°C`);
+  log(`TDP range: ${config.minTdpPercent}% - ${config.maxTdpPercent}%`);
+  log(`TDP step: ${config.tdpStepPercent}%`);
+  log(`Check interval: ${config.checkIntervalMs}ms`);
+  log(`Upp path: ${config.uppPath}`);
+  log(`Config: ${configPath}`);
+  log(`Monitoring ${cardIndices.length} GPU(s)`);
+  log('Press Ctrl+C to stop');
+  log('');
+
+  const gpuStates = cardIndices.map(cardNumber => ({
+    cardNumber,
+    deviceName: getDeviceName(cardNumber),
+    currentTdpPercent: null,
+  }));
+
+  if (gpuStates.length === 0) {
+    log('No GPUs found');
+    process.exit(1);
+  }
+
+  let iteration = 0;
+
+  const interval = setInterval(() => {
+    iteration++;
+
+    for (const gpuState of gpuStates) {
+
+      try {
+        const temp = getTemperature(gpuState.cardNumber);
+        if (temp === null) {
+          log(`GPU ${gpuState.cardNumber} [${gpuState.deviceName}]: Cannot read temperature`);
+          continue;
+        }
+
+        const inRange = temp >= config.minTemp && temp <= config.maxTemp;
+
+        if (!inRange) {
+          const targetTdpPercent = calculateTargetTdpPercent(
+            temp,
+            config.minTemp,
+            config.maxTemp,
+            config.minTdpPercent,
+            config.maxTdpPercent
+          );
+
+          let shouldApply = false;
+
+          if (gpuState.currentTdpPercent === null) {
+            gpuState.currentTdpPercent = targetTdpPercent;
+            shouldApply = true;
+          } else if (targetTdpPercent !== gpuState.currentTdpPercent) {
+            const diff = targetTdpPercent - gpuState.currentTdpPercent;
+
+            if (Math.abs(diff) >= config.tdpStepPercent) {
+              gpuState.currentTdpPercent += Math.sign(diff) * config.tdpStepPercent;
+            } else {
+              gpuState.currentTdpPercent = targetTdpPercent;
+            }
+            shouldApply = true;
+          }
+
+          if (shouldApply) {
+            const clampedPercent = Math.max(config.minTdpPercent, Math.min(config.maxTdpPercent, gpuState.currentTdpPercent));
+            const power = wattsFromPercent(clampedPercent, 250);
+            setTdpForCard(gpuState.cardNumber, power, config);
+            log(`GPU ${gpuState.cardNumber} [${gpuState.deviceName}]: ${temp.toFixed(1)}°C | TDP: ${clampedPercent}% (${power}W)`);
+          }
+        } else if (gpuState.currentTdpPercent !== null && iteration % 10 === 0) {
+          const currentWatts = wattsFromPercent(gpuState.currentTdpPercent, 250);
+          log(`GPU ${gpuState.cardNumber} [${gpuState.deviceName}]: ${temp.toFixed(1)}°C | TDP: ${gpuState.currentTdpPercent}% (${currentWatts}W) (stable)`);
+        }
+      } catch (err) {
+        log(`GPU ${gpuState.cardNumber} [${gpuState.deviceName}]: Error reading temperature - ${err.message}`);
+      }
+    }
+  }, config.checkIntervalMs);
+
+  process.on('SIGINT', () => {
+    clearInterval(interval);
+    log('');
+    log('Stopped.');
+    process.exit(0);
+  });
+}
+
+main();

+ 17 - 0
amd-gpu-temp-control.service

@@ -0,0 +1,17 @@
+[Unit]
+Description=AMD GPU Temperature Control Service
+After=network.target
+
+[Service]
+Type=simple
+ExecStart=/usr/bin/env node /usr/local/bin/amd-gpu-temp-control --config /etc/amd-gpu-temp-control.json
+Restart=on-failure
+RestartSec=5
+StandardOutput=journal
+StandardError=journal
+ProtectSystem=strict
+ProtectHome=read-only
+ReadOnlyPaths=/sys
+
+[Install]
+WantedBy=multi-user.target

+ 37 - 0
install.sh

@@ -0,0 +1,37 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+NAME="amd-gpu-temp-control"
+SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+SRC="$SELF/$NAME.js"
+TPL="$SELF/$NAME.service"
+BINARY="/usr/local/bin/$NAME"
+DEST="/etc/systemd/system/$NAME.service"
+
+install() {
+  [[ -f "$TPL" ]] || { echo "Missing $NAME.service template"; exit 1; }
+  [[ -f "$SRC" ]] || { echo "Missing $NAME.js"; exit 1; }
+  command -v node &>/dev/null || { echo "node not found"; exit 1; }
+  command -v upp &>/dev/null || { echo "upp not found in PATH"; exit 1; }
+
+  cp "$SRC" "$BINARY"
+  chmod +x "$BINARY"
+  cp "$TPL" "$DEST"
+  systemctl daemon-reload
+  systemctl enable "$NAME.service"
+  systemctl restart "$NAME.service"
+  echo "Installed — journalctl -u $NAME -f"
+}
+
+uninstall() {
+  systemctl stop "$NAME" 2>/dev/null || true
+  systemctl disable "$NAME" 2>/dev/null || true
+  rm -f "$DEST" "$BINARY"
+  systemctl daemon-reload
+  echo "Uninstalled."
+}
+
+case "${1:-}" in
+  --uninstall) uninstall ;;
+  *)           install ;;
+esac