amd-gpu-temp-control.js 8.4 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. function loadConfig(configPath) {
  13. if (!fs.existsSync(configPath)) {
  14. return null;
  15. }
  16. const raw = fs.readFileSync(configPath, 'utf-8');
  17. return JSON.parse(raw);
  18. }
  19. function mergeConfig(config) {
  20. return {
  21. minTemp: config.minTemp ?? DEFAULT_MIN_TEMP,
  22. maxTemp: config.maxTemp ?? DEFAULT_MAX_TEMP,
  23. minTdpPercent: config.minTdpPercent ?? DEFAULT_MIN_TDP_PERCENT,
  24. maxTdpPercent: config.maxTdpPercent ?? DEFAULT_MAX_TDP_PERCENT,
  25. tdpStepPercent: config.tdpStepPercent ?? DEFAULT_TDP_STEP_PERCENT,
  26. checkIntervalMs: config.checkIntervalMs ?? DEFAULT_CHECK_INTERVAL_MS,
  27. };
  28. }
  29. function getCardIndices() {
  30. const entries = fs.readdirSync('/sys/class/drm');
  31. const indices = [];
  32. for (const name of entries) {
  33. const match = name.match(/^card(\d+)$/);
  34. if (match) {
  35. indices.push(parseInt(match[1], 10));
  36. }
  37. }
  38. return indices.length > 0 ? indices : [0];
  39. }
  40. function findHwmonDirs(cardNumber) {
  41. const hwmonDirs = [];
  42. const baseDir = `/sys/class/drm/card${cardNumber}/device/hwmon`;
  43. if (fs.existsSync(baseDir)) {
  44. for (const entry of fs.readdirSync(baseDir)) {
  45. if (entry.startsWith('hwmon')) {
  46. hwmonDirs.push(path.join(baseDir, entry));
  47. }
  48. }
  49. }
  50. const altBase = `/sys/class/drm/card${cardNumber}/device`;
  51. if (fs.existsSync(altBase)) {
  52. for (const entry of fs.readdirSync(altBase)) {
  53. if (entry.startsWith('hwmon')) {
  54. const dirPath = path.join(altBase, entry);
  55. if (!hwmonDirs.includes(dirPath)) {
  56. hwmonDirs.push(dirPath);
  57. }
  58. }
  59. }
  60. }
  61. return hwmonDirs;
  62. }
  63. function getTemperature(cardNumber) {
  64. const hwmonDirs = findHwmonDirs(cardNumber);
  65. if (hwmonDirs.length === 0) return null;
  66. let maxTemp = null;
  67. for (const hwmonDir of hwmonDirs) {
  68. try {
  69. const entries = fs.readdirSync(hwmonDir);
  70. for (const entry of entries) {
  71. const match = entry.match(/^temp\d+_input$/);
  72. if (match) {
  73. const raw = fs.readFileSync(path.join(hwmonDir, entry), 'utf-8').trim();
  74. const value = parseInt(raw, 10);
  75. if (!isNaN(value)) {
  76. const tempC = value / 1000;
  77. if (maxTemp === null || tempC > maxTemp) {
  78. maxTemp = tempC;
  79. }
  80. }
  81. }
  82. }
  83. } catch {
  84. }
  85. }
  86. return maxTemp;
  87. }
  88. function getDeviceName(cardNumber) {
  89. try {
  90. const namePath = `/sys/class/drm/card${cardNumber}/device/name`;
  91. if (fs.existsSync(namePath)) {
  92. return fs.readFileSync(namePath, 'utf-8').trim();
  93. }
  94. } catch { /* ignore */ }
  95. try {
  96. const slotPath = `/sys/class/drm/card${cardNumber}/device/uevent`;
  97. if (fs.existsSync(slotPath)) {
  98. const lines = fs.readFileSync(slotPath, 'utf-8').trim().split('\n');
  99. for (const line of lines) {
  100. const match = line.match(/^PCI_SLOT_NAME=(.*)$/);
  101. if (match) {
  102. const slot = match[1].trim();
  103. try {
  104. const out = execSync(`lspci -s ${slot} 2>/dev/null`, { encoding: 'utf-8' }).trim();
  105. if (out) return out.split(/\s{3,}/).pop().trim();
  106. } catch { /* ignore */ }
  107. }
  108. }
  109. }
  110. } catch { /* ignore */ }
  111. return `card${cardNumber}`;
  112. }
  113. function getTdpLimit(cardNumber) {
  114. try {
  115. const out = execSync(`amd-smi static --json -g ${cardNumber}`, { encoding: 'utf-8', stdio: 'pipe' }).trim();
  116. const data = JSON.parse(out);
  117. const gpuData = data.gpu_data[0];
  118. if (gpuData && gpuData.limit && gpuData.limit.ppt0 && gpuData.limit.ppt0.max_power_limit) {
  119. return gpuData.limit.ppt0.max_power_limit.value;
  120. }
  121. } catch { /* ignore */ }
  122. return 100;
  123. }
  124. function setTdpForCard(cardNumber, power) {
  125. execSync(`amd-smi set -g ${cardNumber} -o ppt0 ${power}`, { encoding: 'utf-8', stdio: 'pipe' });
  126. }
  127. function parseArgs(argv) {
  128. let configPath = null;
  129. for (let i = 2; i < argv.length; i++) {
  130. switch (argv[i]) {
  131. case '--config':
  132. case '-c':
  133. configPath = argv[++i];
  134. break;
  135. case '--help':
  136. case '-h':
  137. console.log('Usage: node amd-gpu-temp-control.js [options]');
  138. console.log('');
  139. console.log('Options:');
  140. console.log(' --config, -c <path> Config file path (default: /etc/amd-gpu-temp-control.json)');
  141. console.log(' --help, -h Show this help message');
  142. process.exit(0);
  143. break;
  144. }
  145. }
  146. return configPath || DEFAULT_CONFIG_PATH;
  147. }
  148. function calculateTargetTdpPercent(temp, minTemp, maxTemp, minTdpPercent, maxTdpPercent) {
  149. if (temp <= minTemp) return maxTdpPercent;
  150. if (temp >= maxTemp) return minTdpPercent;
  151. const ratio = (temp - minTemp) / (maxTemp - minTemp);
  152. return Math.round(maxTdpPercent - ratio * (maxTdpPercent - minTdpPercent));
  153. }
  154. function wattsFromPercent(percent, maxTdp) {
  155. return Math.round((percent / 100) * maxTdp);
  156. }
  157. function log(message) {
  158. const timestamp = new Date().toISOString();
  159. console.log(`[${timestamp}] ${message}`);
  160. }
  161. function main() {
  162. const configPath = parseArgs(process.argv);
  163. const rawConfig = loadConfig(configPath);
  164. const config = mergeConfig(rawConfig || {});
  165. const cardIndices = getCardIndices();
  166. log('AMD GPU Temperature Control');
  167. log(`Temperature range: ${config.minTemp}°C - ${config.maxTemp}°C`);
  168. log(`TDP range: ${config.minTdpPercent}% - ${config.maxTdpPercent}%`);
  169. log(`TDP step: ${config.tdpStepPercent}%`);
  170. log(`Check interval: ${config.checkIntervalMs}ms`);
  171. log(`Config: ${configPath}`);
  172. log(`Monitoring ${cardIndices.length} GPU(s)`);
  173. log('Press Ctrl+C to stop');
  174. log('');
  175. const gpuStates = cardIndices.map(cardNumber => ({
  176. cardNumber,
  177. deviceName: getDeviceName(cardNumber),
  178. maxTdp: getTdpLimit(cardNumber),
  179. currentTdpPercent: null,
  180. }));
  181. for (const state of gpuStates) {
  182. log(`GPU ${state.cardNumber} [${state.deviceName}]: max TDP ${state.maxTdp}W`);
  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 power = wattsFromPercent(gpuState.currentTdpPercent, gpuState.maxTdp);
  222. setTdpForCard(gpuState.cardNumber, power);
  223. log(`GPU ${gpuState.cardNumber} [${gpuState.deviceName}]: ${temp.toFixed(1)}°C | TDP: ${gpuState.currentTdpPercent}% (${power}W)`);
  224. }
  225. } else if (gpuState.currentTdpPercent !== null && iteration % 10 === 0) {
  226. const currentWatts = wattsFromPercent(gpuState.currentTdpPercent, gpuState.maxTdp);
  227. log(`GPU ${gpuState.cardNumber} [${gpuState.deviceName}]: ${temp.toFixed(1)}°C | TDP: ${gpuState.currentTdpPercent}% (${currentWatts}W) (stable)`);
  228. }
  229. } catch (err) {
  230. log(`GPU ${gpuState.cardNumber} [${gpuState.deviceName}]: Error reading temperature - ${err.message}`);
  231. }
  232. }
  233. }, config.checkIntervalMs);
  234. process.on('SIGINT', () => {
  235. clearInterval(interval);
  236. log('');
  237. log('Stopped.');
  238. process.exit(0);
  239. });
  240. }
  241. main();