amd-gpu-temp-control.js 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. #!/usr/bin/env node
  2. const { execSync } = require('child_process');
  3. const fs = require('fs');
  4. const path = require('path');
  5. const DEFAULT_CONFIG_PATH = '/etc/amd-gpu-temp-control.json';
  6. const DEFAULT_MIN_TEMP = 70;
  7. const DEFAULT_MAX_TEMP = 80;
  8. const DEFAULT_MIN_TDP_PERCENT = 10;
  9. const DEFAULT_MAX_TDP_PERCENT = 100;
  10. const DEFAULT_TDP_STEP_PERCENT = 10;
  11. const DEFAULT_CHECK_INTERVAL_MS = 2000;
  12. const DEFAULT_CMD_TIMEOUT_MS = 5000;
  13. const DEFAULT_UPP_PATH = 'upp';
  14. function loadConfig(configPath) {
  15. if (!fs.existsSync(configPath)) {
  16. return null;
  17. }
  18. const raw = fs.readFileSync(configPath, 'utf-8');
  19. return JSON.parse(raw);
  20. }
  21. function mergeConfig(config) {
  22. return {
  23. minTemp: config.minTemp ?? DEFAULT_MIN_TEMP,
  24. maxTemp: config.maxTemp ?? DEFAULT_MAX_TEMP,
  25. minTdpPercent: config.minTdpPercent ?? DEFAULT_MIN_TDP_PERCENT,
  26. maxTdpPercent: config.maxTdpPercent ?? DEFAULT_MAX_TDP_PERCENT,
  27. tdpStepPercent: config.tdpStepPercent ?? DEFAULT_TDP_STEP_PERCENT,
  28. checkIntervalMs: config.checkIntervalMs ?? DEFAULT_CHECK_INTERVAL_MS,
  29. cmdTimeoutMs: config.cmdTimeoutMs ?? DEFAULT_CMD_TIMEOUT_MS,
  30. uppPath: config.uppPath ?? DEFAULT_UPP_PATH,
  31. };
  32. }
  33. function getCardIndices() {
  34. const entries = fs.readdirSync('/sys/class/drm');
  35. const indices = [];
  36. for (const name of entries) {
  37. const match = name.match(/^card(\d+)$/);
  38. if (match) {
  39. indices.push(parseInt(match[1], 10));
  40. }
  41. }
  42. return indices.length > 0 ? indices : [0];
  43. }
  44. function findHwmonDirs(cardNumber) {
  45. const hwmonDirs = [];
  46. const baseDir = `/sys/class/drm/card${cardNumber}/device/hwmon`;
  47. if (fs.existsSync(baseDir)) {
  48. for (const entry of fs.readdirSync(baseDir)) {
  49. if (entry.startsWith('hwmon')) {
  50. hwmonDirs.push(path.join(baseDir, entry));
  51. }
  52. }
  53. }
  54. const altBase = `/sys/class/drm/card${cardNumber}/device`;
  55. if (fs.existsSync(altBase)) {
  56. for (const entry of fs.readdirSync(altBase)) {
  57. if (entry.startsWith('hwmon')) {
  58. const dirPath = path.join(altBase, entry);
  59. if (!hwmonDirs.includes(dirPath)) {
  60. hwmonDirs.push(dirPath);
  61. }
  62. }
  63. }
  64. }
  65. return hwmonDirs;
  66. }
  67. function getTemperature(cardNumber) {
  68. const hwmonDirs = findHwmonDirs(cardNumber);
  69. if (hwmonDirs.length === 0) return null;
  70. let maxTemp = null;
  71. for (const hwmonDir of hwmonDirs) {
  72. try {
  73. const entries = fs.readdirSync(hwmonDir);
  74. for (const entry of entries) {
  75. const match = entry.match(/^temp\d+_input$/);
  76. if (match) {
  77. const raw = fs.readFileSync(path.join(hwmonDir, entry), 'utf-8').trim();
  78. const value = parseInt(raw, 10);
  79. if (!isNaN(value)) {
  80. const tempC = value / 1000;
  81. if (maxTemp === null || tempC > maxTemp) {
  82. maxTemp = tempC;
  83. }
  84. }
  85. }
  86. }
  87. } catch {
  88. }
  89. }
  90. return maxTemp;
  91. }
  92. function getDeviceName(cardNumber) {
  93. try {
  94. const namePath = `/sys/class/drm/card${cardNumber}/device/name`;
  95. if (fs.existsSync(namePath)) {
  96. return fs.readFileSync(namePath, 'utf-8').trim();
  97. }
  98. } catch { /* ignore */ }
  99. try {
  100. const slotPath = `/sys/class/drm/card${cardNumber}/device/uevent`;
  101. if (fs.existsSync(slotPath)) {
  102. const lines = fs.readFileSync(slotPath, 'utf-8').trim().split('\n');
  103. for (const line of lines) {
  104. const match = line.match(/^PCI_SLOT_NAME=(.*)$/);
  105. if (match) {
  106. const slot = match[1].trim();
  107. try {
  108. const out = execSync(`lspci -s ${slot} 2>/dev/null`, { encoding: 'utf-8' }).trim();
  109. if (out) return out.split(/\s{3,}/).pop().trim();
  110. } catch { /* ignore */ }
  111. }
  112. }
  113. }
  114. } catch { /* ignore */ }
  115. return `card${cardNumber}`;
  116. }
  117. function getCardPath(cardNumber) {
  118. return `/sys/class/drm/card${cardNumber}/device/pp_table`;
  119. }
  120. function setTdpForCard(cardNumber, power, config) {
  121. const cardPath = getCardPath(cardNumber);
  122. const cmd = `${config.uppPath} -p ${cardPath} set --write ` +
  123. `SmallPowerLimit1=${power} ` +
  124. `SmallPowerLimit2=${power} ` +
  125. `BoostPowerLimit=${power} ` +
  126. `smcPPTable/SocketPowerLimitAc0=${power} ` +
  127. `smcPPTable/SocketPowerLimitDc=${power}`;
  128. execSync(cmd, { encoding: 'utf-8', stdio: 'pipe', timeout: config.cmdTimeoutMs });
  129. }
  130. function parseArgs(argv) {
  131. let configPath = null;
  132. for (let i = 2; i < argv.length; i++) {
  133. switch (argv[i]) {
  134. case '--config':
  135. case '-c':
  136. configPath = argv[++i];
  137. break;
  138. case '--help':
  139. case '-h':
  140. console.log('Usage: node amd-gpu-temp-control.js [options]');
  141. console.log('');
  142. console.log('Options:');
  143. console.log(' --config, -c <path> Config file path (default: /etc/amd-gpu-temp-control.json)');
  144. console.log(' --help, -h Show this help message');
  145. process.exit(0);
  146. break;
  147. }
  148. }
  149. return configPath || DEFAULT_CONFIG_PATH;
  150. }
  151. function calculateTargetTdpPercent(temp, minTemp, maxTemp, minTdpPercent, maxTdpPercent) {
  152. if (temp <= minTemp) return maxTdpPercent;
  153. if (temp >= maxTemp) return minTdpPercent;
  154. const ratio = (temp - minTemp) / (maxTemp - minTemp);
  155. return Math.round(maxTdpPercent - ratio * (maxTdpPercent - minTdpPercent));
  156. }
  157. function wattsFromPercent(percent, maxTdp) {
  158. return Math.round((percent / 100) * maxTdp);
  159. }
  160. function log(message) {
  161. const timestamp = new Date().toISOString();
  162. console.log(`[${timestamp}] ${message}`);
  163. }
  164. async function main() {
  165. const configPath = parseArgs(process.argv);
  166. const rawConfig = loadConfig(configPath);
  167. const config = mergeConfig(rawConfig || {});
  168. const cardIndices = getCardIndices();
  169. log('AMD GPU Temperature Control');
  170. log(`Temperature range: ${config.minTemp}°C - ${config.maxTemp}°C`);
  171. log(`TDP range: ${config.minTdpPercent}% - ${config.maxTdpPercent}%`);
  172. log(`TDP step: ${config.tdpStepPercent}%`);
  173. log(`Check interval: ${config.checkIntervalMs}ms`);
  174. log(`Upp path: ${config.uppPath}`);
  175. log(`Config: ${configPath}`);
  176. log(`Monitoring ${cardIndices.length} GPU(s)`);
  177. log('Press Ctrl+C to stop');
  178. log('');
  179. const gpuStates = cardIndices.map(cardNumber => ({
  180. cardNumber,
  181. deviceName: getDeviceName(cardNumber),
  182. currentTdpPercent: null,
  183. }));
  184. if (gpuStates.length === 0) {
  185. log('No GPUs found');
  186. process.exit(1);
  187. }
  188. let iteration = 0;
  189. const interval = setInterval(() => {
  190. iteration++;
  191. for (const gpuState of gpuStates) {
  192. try {
  193. const temp = getTemperature(gpuState.cardNumber);
  194. if (temp === null) {
  195. log(`GPU ${gpuState.cardNumber} [${gpuState.deviceName}]: Cannot read temperature`);
  196. continue;
  197. }
  198. const inRange = temp >= config.minTemp && temp <= config.maxTemp;
  199. if (!inRange) {
  200. const targetTdpPercent = calculateTargetTdpPercent(
  201. temp,
  202. config.minTemp,
  203. config.maxTemp,
  204. config.minTdpPercent,
  205. config.maxTdpPercent
  206. );
  207. let shouldApply = false;
  208. if (gpuState.currentTdpPercent === null) {
  209. gpuState.currentTdpPercent = targetTdpPercent;
  210. shouldApply = true;
  211. } else if (targetTdpPercent !== gpuState.currentTdpPercent) {
  212. const diff = targetTdpPercent - gpuState.currentTdpPercent;
  213. if (Math.abs(diff) >= config.tdpStepPercent) {
  214. gpuState.currentTdpPercent += Math.sign(diff) * config.tdpStepPercent;
  215. } else {
  216. gpuState.currentTdpPercent = targetTdpPercent;
  217. }
  218. shouldApply = true;
  219. }
  220. if (shouldApply) {
  221. const clampedPercent = Math.max(config.minTdpPercent, Math.min(config.maxTdpPercent, gpuState.currentTdpPercent));
  222. const power = wattsFromPercent(clampedPercent, 250);
  223. setTdpForCard(gpuState.cardNumber, power, config);
  224. log(`GPU ${gpuState.cardNumber} [${gpuState.deviceName}]: ${temp.toFixed(1)}°C | TDP: ${clampedPercent}% (${power}W)`);
  225. }
  226. } else if (gpuState.currentTdpPercent !== null && iteration % 10 === 0) {
  227. const currentWatts = wattsFromPercent(gpuState.currentTdpPercent, 250);
  228. log(`GPU ${gpuState.cardNumber} [${gpuState.deviceName}]: ${temp.toFixed(1)}°C | TDP: ${gpuState.currentTdpPercent}% (${currentWatts}W) (stable)`);
  229. }
  230. } catch (err) {
  231. log(`GPU ${gpuState.cardNumber} [${gpuState.deviceName}]: Error reading temperature - ${err.message}`);
  232. }
  233. }
  234. }, config.checkIntervalMs);
  235. process.on('SIGINT', () => {
  236. clearInterval(interval);
  237. log('');
  238. log('Stopped.');
  239. process.exit(0);
  240. });
  241. }
  242. main();