From d11d97a55c9f02199b96da0b057400b5a49d02a8 Mon Sep 17 00:00:00 2001 From: jsboige Date: Sun, 6 Sep 2026 23:43:59 +0200 Subject: [PATCH] feat(genai-stack): ajouter gpu lock on/off/status/register (verrou clocks GPU) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le verrou undervolt applique nvidia-smi -lgc 210,1800 au boot, journalise ce qui a ete pose dans %LOCALAPPDATA%\myia\gpu_lock.log et verifie le resultat via `nvidia-smi -q -d CLOCK` (parse du Graphics courant/max). La commande register prepare la tache planifiee (schtasks /create onstart/onlogon) en dry-run [INTERACTIVE-ONLY] : le geste UAC n'est PAS execute ici. Tests : test_genai_stack_gpu_lock.py (14 cas, hermétiques via mock de _run_cmd/subprocess, journal en tmp). Suite genai-stack : 187 pass, 1 echec pre-existant hors scope (test_updates_aliases, auth). Co-Authored-By: Claude-Code --- scripts/genai-stack/commands/gpu.py | 186 ++++++++++++++++ .../tests/test_genai_stack_gpu_lock.py | 204 ++++++++++++++++++ 2 files changed, 390 insertions(+) create mode 100644 scripts/genai-stack/tests/test_genai_stack_gpu_lock.py diff --git a/scripts/genai-stack/commands/gpu.py b/scripts/genai-stack/commands/gpu.py index f4b2c9b8a9..75c2cbc2ac 100644 --- a/scripts/genai-stack/commands/gpu.py +++ b/scripts/genai-stack/commands/gpu.py @@ -10,14 +10,21 @@ genai.py gpu profile current # Detecter le profil actuel genai.py gpu check-fit # Verifier si un modele tient en VRAM genai.py gpu schedule # Appliquer le profil pour un groupe de notebooks + genai.py gpu lock on # Poser le verrou de clocks GPU (undervolt lock) + genai.py gpu lock off # Retirer le verrou de clocks GPU + genai.py gpu lock status # Afficher les clocks GPU courantes/max + genai.py gpu lock register # Planifier la tache au demarrage ([INTERACTIVE-ONLY]) """ import subprocess import csv import io import json +import os +import re import sys import time +from datetime import datetime import logging from pathlib import Path from typing import List, Dict, Optional @@ -417,6 +424,160 @@ def schedule_group(group_name: str) -> bool: return profile_apply(profile_name) +# ============================================================================ +# Verrou de clocks GPU (undervolt lock) - persistant au demarrage +# ============================================================================ + +GPU_LOCK_MIN_MHZ = 210 +GPU_LOCK_MAX_MHZ = 1800 +GPU_LOCK_CLOCKS = f"{GPU_LOCK_MIN_MHZ},{GPU_LOCK_MAX_MHZ}" + + +def _gpu_lock_log_path() -> Path: + """Chemin absolu du journal du verrou GPU (user-scope, hors repo).""" + base = os.environ.get("LOCALAPPDATA") or os.environ.get("XDG_STATE_HOME") or str(Path.home() / ".local" / "state") + log_dir = Path(base) / "myia" + log_dir.mkdir(parents=True, exist_ok=True) + return log_dir / "gpu_lock.log" + + +def _gpu_lock_journal(action: str, state: str, detail: str) -> str: + """Ajoute une ligne horodatee au journal du verrou GPU. Retourne la ligne.""" + ts = datetime.now().strftime("%Y-%m-%dT%H:%M:%S") + line = f"[{ts}] {action} -> {state} | {detail}" + try: + with open(_gpu_lock_log_path(), "a", encoding="utf-8") as f: + f.write(line + "\n") + except OSError as e: + logger.warning("gpu-lock: journal inecrivable: %s", e) + print(line) + return line + + +def _parse_mhz(value: str) -> Optional[int]: + """Extrait une valeur entiere en MHz ('1230 MHz' -> 1230, sinon None).""" + m = re.search(r"(\d+)\s*MHz", value, re.IGNORECASE) + return int(m.group(1)) if m else None + + +def _parse_clocks_stdout(stdout: str) -> tuple: + """Extrait (courant_mhz, max_mhz) du Graphics depuis `nvidia-smi -q -d CLOCK`.""" + current = None + max_clock = None + section = None + for line in stdout.splitlines(): + s = line.strip() + if not s: + continue + if ":" not in s: + # En-tete de section (libelle indente sans deux-points), ex: "Clocks", "Max Clocks". + section = s + continue + label, _, value = s.partition(":") + label = label.strip() + value = value.strip() + if label == "Graphics": + mhz = _parse_mhz(value) + if mhz is None: + continue + if section == "Clocks": + current = mhz + elif section == "Max Clocks": + max_clock = mhz + return current, max_clock + + +def _print_lock_status(current: Optional[int], max_clock: Optional[int]): + """Affiche les clocks Graphics courantes et max.""" + cur_txt = f"{current} MHz" if current is not None else "indisponible" + max_txt = f"{max_clock} MHz" if max_clock is not None else "indisponible" + print(" Clocks Graphics (courant) :", cur_txt) + print(" Max Clocks Graphics (max) :", max_txt) + + +def gpu_lock_status() -> Optional[Dict[str, Optional[int]]]: + """Lit `nvidia-smi -q -d CLOCK` et retourne {'current_mhz': int|None, 'max_mhz': int|None}.""" + ok, stdout, stderr = _run_cmd("nvidia-smi -q -d CLOCK") + if not ok: + print("[gpu-lock] ECHEC: nvidia-smi -q -d CLOCK a retourne un code non nul:") + print(" " + (stderr.strip() or stdout.strip() or "rc != 0")) + return None + current, max_clock = _parse_clocks_stdout(stdout) + _print_lock_status(current, max_clock) + return {"current_mhz": current, "max_mhz": max_clock} + + +def _verify_lock(enable: bool, current: Optional[int], max_clock: Optional[int]) -> str: + """Verdict du verrou : OK / ECHEC / INDETERMINE selon les clocks observees.""" + if enable: + # Verrou pose : la butee max appliquee ne doit pas depasser la butee configuree. + if max_clock is not None: + return "OK" if max_clock <= GPU_LOCK_MAX_MHZ else "ECHEC" + return "INDETERMINE" + # off : la limite max doit redevenir libre (au-dela de la butee). + if max_clock is not None: + return "OK" if max_clock > GPU_LOCK_MAX_MHZ else "ECHEC" + return "INDETERMINE" + + +def gpu_lock_apply(enable: bool, clocks: str = GPU_LOCK_CLOCKS) -> bool: + """Applique (enable=True, `nvidia-smi -lgc`) ou retire (False, `nvidia-smi -rgc`) le verrou. + + Journalise la commande, le rc et la verification dans _gpu_lock_log_path(). + Retourne True si la commande a reussi (le verdict de verification est journalise + affiche). + """ + if enable: + cmd = f"nvidia-smi -lgc {clocks}" + action = f"lock-on {clocks}" + else: + cmd = "nvidia-smi -rgc" + action = "lock-off" + ok, stdout, stderr = _run_cmd(cmd) + if not ok: + _gpu_lock_journal(action, "ECHEC", "cmd=%s rc!=0: %s" % (cmd, (stderr.strip() or stdout.strip())[:200])) + print(f"[gpu-lock] ECHEC: {cmd}") + print(" " + (stderr.strip() or stdout.strip())) + return False + status = gpu_lock_status() + if status is None: + _gpu_lock_journal(action, "INDETERMINE", "cmd=%s verif: nvidia-smi -q -d CLOCK invalide" % cmd) + return True + current = status["current_mhz"] + max_clock = status["max_mhz"] + verdict = _verify_lock(enable, current, max_clock) + _gpu_lock_journal(action, verdict, "cmd=%s || courant=%sMHz max=%sMHz" % (cmd, current, max_clock)) + print(" Verification:", verdict) + print(" Journal :", _gpu_lock_log_path()) + return True + + +def gpu_lock_register() -> None: + """Affiche (sans l'executer) la commande de planification de la tache au demarrage. + + La planification touche UAC (elevation) => [INTERACTIVE-ONLY] : ce dry-run ne fait + que preparer la commande a recoller dans une invite Administrateur. + """ + genai_py = _script_dir / "genai.py" + print("=" * 70) + print(" PLANIFICATION TACHE AUTO - VERROU CLOCKS AU DEMARRAGE") + print(" [INTERACTIVE-ONLY] : execution requiert une invite Administrateur (UAC)") + print(" Aucune action n'a ete effectuee (dry-run).") + print("=" * 70) + print() + print(" Version boot (onstart, SYSTEM, privilege HIGHEST) :") + inner = '\\"python\\" \\"%s\\" gpu lock on' % genai_py + print(' schtasks /create /tn "MyIA_GPUUndervoltClockLock" /tr "%s" /sc onstart /ru SYSTEM /rl HIGHEST /f' % inner) + print() + print(" Alternative logon (onlogon, utilisateur connecte) :") + print(' schtasks /create /tn "MyIA_GPUUndervoltClockLock" /tr "%s" /sc onlogon /rl HIGHEST /f' % inner) + print() + print(" Pour supprimer la tache plus tard :") + print(' schtasks /delete /tn "MyIA_GPUUndervoltClockLock" /f') + print() + print(" NOTE : adapter le chemin python si besoin (ex: full path python.exe / py -3.11).") + print(" La tache journalise dans", _gpu_lock_log_path()) + + # ============================================================================ # CLI # ============================================================================ @@ -456,6 +617,16 @@ def register(subparsers): p_schedule.add_argument('group', choices=list(GROUP_GPU_PROFILE.keys()), help='Groupe de notebooks') + # gpu lock + p_lock = sub.add_parser('lock', help='Verrou de clocks GPU (undervolt, persistant au boot)') + lock_sub = p_lock.add_subparsers(dest='lock_action') + p_lock_on = lock_sub.add_parser('on', help='Appliquer le verrou (nvidia-smi -lgc)') + p_lock_on.add_argument('--clocks', default=GPU_LOCK_CLOCKS, + help='Plage MIN,MAX en MHz (defaut: 210,1800)') + lock_sub.add_parser('off', help='Retirer le verrou (nvidia-smi -rgc)') + lock_sub.add_parser('status', help='Afficher l etat des clocks GPU') + lock_sub.add_parser('register', help='Planifier la tache au demarrage ([INTERACTIVE-ONLY])') + def execute(args) -> int: """Execute la commande gpu.""" @@ -485,6 +656,21 @@ def execute(args) -> int: ok = schedule_group(args.group) return 0 if ok else 1 + elif action == 'lock': + lock_action = getattr(args, 'lock_action', None) + if lock_action == 'on': + ok = gpu_lock_apply(True, clocks=args.clocks) + return 0 if ok else 1 + elif lock_action == 'off': + ok = gpu_lock_apply(False) + return 0 if ok else 1 + elif lock_action == 'status': + st = gpu_lock_status() + return 0 if st is not None else 1 + else: # lock (sans sous-commande) ou register + gpu_lock_register() + return 0 + # Commande gpu sans sous-commande : comportement original if getattr(args, 'detailed', False): ok = check_gpu_detailed() diff --git a/scripts/genai-stack/tests/test_genai_stack_gpu_lock.py b/scripts/genai-stack/tests/test_genai_stack_gpu_lock.py new file mode 100644 index 0000000000..5ff9303d51 --- /dev/null +++ b/scripts/genai-stack/tests/test_genai_stack_gpu_lock.py @@ -0,0 +1,204 @@ +# -*- coding: utf-8 -*- +"""Tests unitaires pour le verrou de clocks GPU (commands/gpu.py). + +La suite est hermetique : nvidia-smi n'est jamais execute (mock de _run_cmd), +le journal est ecrit dans un dossier temporaire. Verifiables sur n'importe quel +runner (pas de GPU requis). + +Voir : docs/genai/genai-services.md (verrou clocks GPU / undervolt lock). +""" + +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +# Sortie reelle capturee sur la machine (nvidia-smi -q -d CLOCK). Le parseur doit +# extraire Graphics courant=1230 sous "Clocks" et Graphics max=2100 sous "Max Clocks", +# tout en ignorant les valeurs "deprecated" / N/A / "Not Found" des autres sections. +CLOCK_OUTPUT = """==============NVSMI LOG============== + +Timestamp : Sun Sep 6 23:35:19 2026 +Driver Version : 616.56 +CUDA Version : 13.4 + +Attached GPUs : 1 +GPU 00000000:01:00.0 + Clocks + Graphics : 1230 MHz + SM : 1230 MHz + Memory : 6001 MHz + Video : 1080 MHz + Applications Clocks + Graphics : Requested functionality has been deprecated + Memory : Requested functionality has been deprecated + Default Applications Clocks + Graphics : Requested functionality has been deprecated + Memory : Requested functionality has been deprecated + Deferred Clocks + Memory : N/A + Max Clocks + Graphics : 2100 MHz + SM : 2100 MHz + Memory : 8001 MHz + Video : 1950 MHz + Max Customer Boost Clocks + Graphics : N/A + SM Clock Samples + Duration : Not Found + Number of Samples : Not Found + Max : Not Found + Min : Not Found + Avg : Not Found + Clock Policy + Auto Boost : N/A + Auto Boost Default : N/A +""" + + +class TestGpuLockParse(unittest.TestCase): + """Parseur des valeurs de clocks.""" + + def test_parse_mhz_values(self): + from commands.gpu import _parse_mhz + self.assertEqual(_parse_mhz("1230 MHz"), 1230) + self.assertEqual(_parse_mhz(" 1800 MHz"), 1800) + self.assertIsNone(_parse_mhz("N/A")) + self.assertIsNone(_parse_mhz("Requested functionality has been deprecated")) + self.assertIsNone(_parse_mhz("Not Found")) + self.assertIsNone(_parse_mhz("")) + + def test_parse_clocks_stdout_real_format(self): + from commands.gpu import _parse_clocks_stdout + current, max_clock = _parse_clocks_stdout(CLOCK_OUTPUT) + self.assertEqual(current, 1230) + self.assertEqual(max_clock, 2100) + + def test_parse_clocks_stdout_ignores_other_sections(self): + # Une valeur Graphics sous une section non ciblee ne doit pas ecraser. + from commands.gpu import _parse_clocks_stdout + out = (" Clocks\n Graphics : 1230 MHz\n" + " Max Clocks\n Graphics : 2100 MHz\n" + " Max Customer Boost Clocks\n Graphics : N/A\n") + current, max_clock = _parse_clocks_stdout(out) + self.assertEqual(current, 1230) + self.assertEqual(max_clock, 2100) + + def test_parse_clocks_stdout_no_graphics(self): + from commands.gpu import _parse_clocks_stdout + current, max_clock = _parse_clocks_stdout(" Clocks\n SM : 1230 MHz\n") + self.assertIsNone(current) + self.assertIsNone(max_clock) + + +class TestGpuLockStatus(unittest.TestCase): + """gpu_lock_status (lecture seule, mock de _run_cmd).""" + + @patch("commands.gpu._run_cmd") + def test_status_reads_clocks(self, mock_run): + from commands.gpu import gpu_lock_status + mock_run.return_value = (True, CLOCK_OUTPUT, "") + st = gpu_lock_status() + self.assertEqual(st, {"current_mhz": 1230, "max_mhz": 2100}) + + @patch("commands.gpu._run_cmd") + def test_status_nvidia_fail_returns_none(self, mock_run): + from commands.gpu import gpu_lock_status + mock_run.return_value = (False, "", "driver error") + self.assertIsNone(gpu_lock_status()) + + +class TestGpuLockVerify(unittest.TestCase): + """Verdict du verrou selon les clocks observees.""" + + def test_verify_lock_on(self): + from commands.gpu import _verify_lock + self.assertEqual(_verify_lock(True, 1380, 1800), "OK") + self.assertEqual(_verify_lock(True, 1380, 2100), "ECHEC") + self.assertEqual(_verify_lock(True, 1380, None), "INDETERMINE") + + def test_verify_lock_off(self): + from commands.gpu import _verify_lock + self.assertEqual(_verify_lock(False, 1380, 2100), "OK") + self.assertEqual(_verify_lock(False, 1380, 1800), "ECHEC") + self.assertEqual(_verify_lock(False, 1380, None), "INDETERMINE") + + +class TestGpuLockApply(unittest.TestCase): + """gpu_lock_apply (nvidia-smi -lgc / -rgc, mocke).""" + + @patch("commands.gpu._gpu_lock_journal") + @patch("commands.gpu.gpu_lock_status") + @patch("commands.gpu._run_cmd") + def test_apply_on_verified_capped(self, mock_run, mock_status, mock_journal): + from commands.gpu import gpu_lock_apply + mock_run.return_value = (True, "", "") + mock_status.return_value = {"current_mhz": 1800, "max_mhz": 1800} + self.assertTrue(gpu_lock_apply(True)) + self.assertIn("-lgc 210,1800", mock_run.call_args_list[0][0][0]) + # journalise le verdict OK + self.assertEqual(mock_journal.call_args[0][1], "OK") + + @patch("commands.gpu._gpu_lock_journal") + @patch("commands.gpu.gpu_lock_status") + @patch("commands.gpu._run_cmd") + def test_apply_on_not_capped_journalized_echac(self, mock_run, mock_status, mock_journal): + from commands.gpu import gpu_lock_apply + mock_run.return_value = (True, "", "") + mock_status.return_value = {"current_mhz": 1380, "max_mhz": 2100} + self.assertTrue(gpu_lock_apply(True)) + self.assertEqual(mock_journal.call_args[0][1], "ECHEC") + + @patch("commands.gpu.gpu_lock_status") + @patch("commands.gpu._run_cmd") + def test_apply_on_cmd_fail_returns_false(self, mock_run, mock_status): + from commands.gpu import gpu_lock_apply + mock_run.return_value = (False, "", "rc!=0") + mock_status.return_value = {"current_mhz": 1380, "max_mhz": 2100} + self.assertFalse(gpu_lock_apply(True)) + # si la commande echoue, on ne relit pas l'etat + self.assertEqual(mock_status.call_count, 0) + + @patch("commands.gpu._gpu_lock_journal") + @patch("commands.gpu.gpu_lock_status") + @patch("commands.gpu._run_cmd") + def test_apply_off_uncapped(self, mock_run, mock_status, mock_journal): + from commands.gpu import gpu_lock_apply + mock_run.return_value = (True, "", "") + mock_status.return_value = {"current_mhz": 1380, "max_mhz": 2100} + self.assertTrue(gpu_lock_apply(False)) + self.assertIn("-rgc", mock_run.call_args_list[0][0][0]) + self.assertEqual(mock_journal.call_args[0][1], "OK") + + +class TestGpuLockJournal(unittest.TestCase): + """Journal hermetique (ecrit dans un dossier temporaire).""" + + def test_journal_writes_line(self): + from commands.gpu import _gpu_lock_journal + with tempfile.TemporaryDirectory() as td: + log = Path(td) / "gpu_lock.log" + with patch("commands.gpu._gpu_lock_log_path", return_value=log): + line = _gpu_lock_journal("lock-on 210,1800", "OK", "courant=1800MHz max=1800MHz") + self.assertIn("lock-on 210,1800 -> OK", line) + content = log.read_text(encoding="utf-8") + self.assertIn("lock-on 210,1800 -> OK", content) + + +class TestGpuLockRegister(unittest.TestCase): + """register : dry-run, aucune execution.""" + + def test_register_is_dry_run(self): + from commands.gpu import gpu_lock_register + with patch("commands.gpu._gpu_lock_log_path", return_value=Path("gpu_lock.log")): + with patch("builtins.print") as mock_print: + gpu_lock_register() + out = "\n".join(str(c[0]) for c in mock_print.call_args_list) + self.assertIn("schtasks /create", out) + self.assertIn("onstart", out) + self.assertIn("INTERACTIVE-ONLY", out) + self.assertIn("/delete", out) + + +if __name__ == "__main__": + unittest.main()