| |
| """ |
| AI Studio GPU Worker. |
| |
| Ce worker expose un serveur HTTP local. Le serveur Hugging Face Space l'appelle |
| via l'URL ngrok enregistrée dans l'interface. Les requêtes du Space vers ngrok |
| incluent le header `ngrok-skip-browser-warning: true`. |
| |
| Installation minimale: |
| pip install psutil pynvml |
| |
| Pour l'entraînement Hugging Face: |
| pip install torch transformers datasets peft accelerate evaluate scikit-learn |
| |
| Lancement exemple: |
| python worker.py --server-url https://ton-space.hf.space --user-token TON_TOKEN --worker-name "Colab T4" --port 8765 |
| """ |
|
|
| import argparse |
| import json |
| import signal |
| import sys |
| import threading |
| import time |
| import traceback |
| from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer |
| from urllib.error import HTTPError, URLError |
| from urllib.request import Request, urlopen |
|
|
| try: |
| import psutil |
| except Exception: |
| psutil = None |
|
|
| try: |
| import pynvml |
| except Exception: |
| pynvml = None |
|
|
| STATE = { |
| "server_url": "", |
| "user_token": "", |
| "worker_name": "", |
| "ngrok_url": "", |
| "current_job": None, |
| "stop_event": None, |
| "lock": threading.Lock(), |
| "started_at": time.time() |
| } |
|
|
|
|
| def log(message): |
| print(f"[worker] {message}", flush=True) |
|
|
|
|
| def post_json(path, payload, token=None, timeout=20): |
| url = f"{STATE['server_url'].rstrip('/')}{path}" |
| body = json.dumps(payload).encode("utf-8") |
| headers = { |
| "Content-Type": "application/json", |
| "ngrok-skip-browser-warning": "true" |
| } |
|
|
| if token: |
| headers["Authorization"] = f"Bearer {token}" |
|
|
| request = Request(url, data=body, headers=headers, method="POST") |
|
|
| try: |
| with urlopen(request, timeout=timeout) as response: |
| raw = response.read().decode("utf-8") |
| return json.loads(raw) if raw else {} |
| except HTTPError as error: |
| raw = error.read().decode("utf-8", errors="replace") |
| raise RuntimeError(f"POST {path} -> HTTP {error.code}: {raw}") from error |
| except URLError as error: |
| raise RuntimeError(f"POST {path} -> {error}") from error |
|
|
|
|
| def send_job_log(job_id, level, message, progress=None): |
| try: |
| post_json( |
| f"/api/jobs/{job_id}/logs", |
| { |
| "level": level, |
| "message": message, |
| "progress": progress |
| }, |
| token=STATE["user_token"], |
| timeout=15 |
| ) |
| except Exception as exc: |
| log(f"Impossible d'envoyer le log: {exc}") |
|
|
|
|
| def send_job_status(job_id, status, progress=None, error=None): |
| try: |
| post_json( |
| f"/api/jobs/{job_id}/status", |
| { |
| "status": status, |
| "progress": progress, |
| "error": error |
| }, |
| token=STATE["user_token"], |
| timeout=20 |
| ) |
| except Exception as exc: |
| log(f"Impossible d'envoyer le statut: {exc}") |
|
|
|
|
| def decode_nvml_value(value): |
| if isinstance(value, bytes): |
| return value.decode("utf-8", errors="ignore") |
| return value |
|
|
|
|
| def get_gpu_metrics(): |
| metrics = { |
| "gpu_count": 0, |
| "gpu_name": None, |
| "gpu_index": 0, |
| "vram_total": 0, |
| "vram_used": 0, |
| "vram_free": 0, |
| "gpu_util": 0, |
| "gpu_temperature": None |
| } |
|
|
| if pynvml is not None: |
| try: |
| pynvml.nvmlInit() |
| count = pynvml.nvmlDeviceGetCount() |
| metrics["gpu_count"] = count |
|
|
| if count > 0: |
| handle = pynvml.nvmlDeviceGetHandleByIndex(0) |
| name = decode_nvml_value(pynvml.nvmlDeviceGetName(handle)) |
| memory = pynvml.nvmlDeviceGetMemoryInfo(handle) |
| utilization = pynvml.nvmlDeviceGetUtilizationRates(handle) |
|
|
| metrics.update({ |
| "gpu_name": name, |
| "gpu_index": 0, |
| "vram_total": int(memory.total), |
| "vram_used": int(memory.used), |
| "vram_free": int(memory.free), |
| "gpu_util": int(utilization.gpu), |
| "gpu_temperature": int(pynvml.nvmlDeviceGetTemperature( |
| handle, |
| pynvml.NVML_TEMPERATURE_GPU |
| )) |
| }) |
|
|
| pynvml.nvmlShutdown() |
| return metrics |
| except Exception as exc: |
| metrics["gpu_error"] = f"pynvml: {exc}" |
|
|
| try: |
| import subprocess |
|
|
| command = [ |
| "nvidia-smi", |
| "--query-gpu=name,memory.total,memory.used,memory.free,utilization.gpu,temperature.gpu", |
| "--format=csv,noheader,nounits" |
| ] |
| result = subprocess.run(command, check=True, capture_output=True, text=True, timeout=10) |
| lines = [line for line in result.stdout.splitlines() if line.strip()] |
|
|
| if lines: |
| parts = [part.strip() for part in lines[0].split(",")] |
| if len(parts) >= 6: |
| metrics.update({ |
| "gpu_count": len(lines), |
| "gpu_name": parts[0], |
| "gpu_index": 0, |
| "vram_total": int(float(parts[1]) * 1024 * 1024), |
| "vram_used": int(float(parts[2]) * 1024 * 1024), |
| "vram_free": int(float(parts[3]) * 1024 * 1024), |
| "gpu_util": int(float(parts[4])), |
| "gpu_temperature": int(float(parts[5])) |
| }) |
| except Exception as exc: |
| if "gpu_error" not in metrics: |
| metrics["gpu_error"] = f"nvidia-smi: {exc}" |
|
|
| return metrics |
|
|
|
|
| def get_metrics(): |
| metrics = { |
| "worker_url": STATE["ngrok_url"], |
| "worker_name": STATE["worker_name"], |
| "uptime_seconds": int(time.time() - STATE["started_at"]), |
| "current_job": STATE["current_job"], |
| "python_version": sys.version.split()[0] |
| } |
|
|
| if psutil is not None: |
| try: |
| cpu = psutil.cpu_percent(interval=0.2) |
| memory = psutil.virtual_memory() |
| disk = psutil.disk_usage("/") |
|
|
| metrics.update({ |
| "cpu_percent": cpu, |
| "cpu_count": psutil.cpu_count(logical=True), |
| "ram_total": int(memory.total), |
| "ram_used": int(memory.used), |
| "ram_free": int(memory.free), |
| "ram_percent": memory.percent, |
| "disk_total": int(disk.total), |
| "disk_used": int(disk.used), |
| "disk_free": int(disk.free), |
| "disk_percent": disk.percent |
| }) |
| except Exception as exc: |
| metrics["system_error"] = f"psutil: {exc}" |
| else: |
| metrics["system_error"] = "psutil non installé" |
|
|
| metrics.update(get_gpu_metrics()) |
| return metrics |
|
|
|
|
| def register_worker(): |
| try: |
| with STATE["lock"]: |
| current_job = STATE["current_job"] |
|
|
| post_json( |
| "/api/workers/register", |
| { |
| "userToken": STATE["user_token"], |
| "workerName": STATE["worker_name"], |
| "status": "busy" if current_job else "online", |
| "metrics": get_metrics() |
| }, |
| timeout=15 |
| ) |
| log("Worker enregistré auprès du serveur.") |
| except Exception as exc: |
| log(f"Échec de l'enregistrement: {exc}") |
|
|
|
|
| def heartbeat_loop(interval): |
| while True: |
| time.sleep(interval) |
| register_worker() |
|
|
|
|
| def run_job_thread(job, stop_event): |
| job_id = job.get("jobId") |
| log(f"Démarrage du job {job_id}") |
| send_job_status(job_id, "running", progress=0) |
| send_job_log(job_id, "info", "Démarrage de l'entraînement sur le worker GPU.", progress=0) |
|
|
| try: |
| from train_hf import run_training |
|
|
| result = run_training( |
| job=job, |
| log_callback=lambda level, message, progress=None: send_job_log(job_id, level, message, progress), |
| stop_event=stop_event |
| ) |
|
|
| if stop_event.is_set(): |
| send_job_status(job_id, "cancelled", progress=100) |
| send_job_log(job_id, "warning", "Job arrêté par l'utilisateur.", progress=100) |
| else: |
| send_job_status(job_id, "completed", progress=100) |
| send_job_log( |
| job_id, |
| "success", |
| f"Entraînement terminé. Sortie: {result.get('output_dir', 'inconnue')}", |
| progress=100 |
| ) |
| except KeyboardInterrupt: |
| send_job_status(job_id, "cancelled", progress=100) |
| send_job_log(job_id, "warning", "Job annulé.", progress=100) |
| except Exception as exc: |
| log(traceback.format_exc()) |
| send_job_status(job_id, "failed", error=str(exc)) |
| send_job_log(job_id, "error", f"Erreur pendant l'entraînement: {exc}") |
| finally: |
| with STATE["lock"]: |
| STATE["current_job"] = None |
| STATE["stop_event"] = None |
| log(f"Job {job_id} terminé.") |
|
|
|
|
| class WorkerHandler(BaseHTTPRequestHandler): |
| server_version = "AIStudioWorker/1.0" |
|
|
| def log_message(self, format, *args): |
| log(f"HTTP {args[0] if args else ''}") |
|
|
| def send_json(self, status, payload): |
| body = json.dumps(payload, ensure_ascii=False).encode("utf-8") |
| self.send_response(status) |
| self.send_header("Content-Type", "application/json; charset=utf-8") |
| self.send_header("Content-Length", str(len(body))) |
| self.send_header("Access-Control-Allow-Origin", "*") |
| self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization, ngrok-skip-browser-warning") |
| self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") |
| self.end_headers() |
| self.wfile.write(body) |
|
|
| def read_json(self): |
| length = int(self.headers.get("Content-Length", "0") or 0) |
| if not length: |
| return {} |
|
|
| raw = self.rfile.read(length).decode("utf-8") |
| return json.loads(raw) if raw else {} |
|
|
| def do_OPTIONS(self): |
| self.send_response(204) |
| self.send_header("Access-Control-Allow-Origin", "*") |
| self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization, ngrok-skip-browser-warning") |
| self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") |
| self.end_headers() |
|
|
| def do_GET(self): |
| if self.path == "/health": |
| with STATE["lock"]: |
| current_job = STATE["current_job"] |
| stop_requested = bool(STATE["stop_event"].is_set() if STATE["stop_event"] else False) |
|
|
| self.send_json(200, { |
| "ok": True, |
| "status": "busy" if current_job else "idle", |
| "current_job": current_job, |
| "stop_requested": stop_requested, |
| "ngrok_url": STATE["ngrok_url"], |
| "worker_name": STATE["worker_name"], |
| "metrics": get_metrics() |
| }) |
| return |
|
|
| if self.path == "/metrics": |
| self.send_json(200, get_metrics()) |
| return |
|
|
| self.send_json(404, {"error": "Endpoint inconnu."}) |
|
|
| def do_POST(self): |
| try: |
| payload = self.read_json() |
|
|
| if self.path == "/start-job": |
| with STATE["lock"]: |
| if STATE["current_job"]: |
| self.send_json(409, { |
| "ok": False, |
| "error": "Un job est déjà en cours sur ce worker." |
| }) |
| return |
|
|
| STATE["current_job"] = payload.get("jobId") |
| STATE["stop_event"] = threading.Event() |
| stop_event = STATE["stop_event"] |
|
|
| thread = threading.Thread( |
| target=run_job_thread, |
| args=(payload, stop_event), |
| daemon=True |
| ) |
| thread.start() |
| self.send_json(200, {"ok": True, "jobId": payload.get("jobId")}) |
| return |
|
|
| if self.path == "/stop-job": |
| job_id = payload.get("jobId") |
| with STATE["lock"]: |
| current_job = STATE["current_job"] |
| if current_job and current_job == job_id and STATE["stop_event"]: |
| STATE["stop_event"].set() |
| self.send_json(200, {"ok": True, "jobId": job_id, "stop_requested": True}) |
| else: |
| self.send_json(404, {"ok": False, "error": "Aucun job correspondant n'est en cours."}) |
| return |
|
|
| self.send_json(404, {"error": "Endpoint inconnu."}) |
| except Exception as exc: |
| self.send_json(500, {"error": str(exc)}) |
|
|
|
|
| def parse_args(): |
| parser = argparse.ArgumentParser(description="AI Studio GPU Worker") |
| parser.add_argument("--server-url", required=True, help="URL du serveur AI Studio, par exemple https://ton-space.hf.space") |
| parser.add_argument("--user-token", required=True, help="Token utilisateur pour s'authentifier auprès du serveur") |
| parser.add_argument("--worker-name", default="GPU Worker", help="Nom de la machine worker") |
| parser.add_argument("--ngrok-url", default="", help="URL publique ngrok, utile pour l'affichage et le diagnostic") |
| parser.add_argument("--host", default="0.0.0.0") |
| parser.add_argument("--port", type=int, default=8765) |
| parser.add_argument("--heartbeat-interval", type=int, default=15) |
| return parser.parse_args() |
|
|
|
|
| def main(): |
| args = parse_args() |
| STATE["server_url"] = args.server_url.rstrip("/") |
| STATE["user_token"] = args.user_token |
| STATE["worker_name"] = args.worker_name |
| STATE["ngrok_url"] = args.ngrok_url |
|
|
| def shutdown(signum, frame): |
| log("Signal reçu, arrêt du worker...") |
| with STATE["lock"]: |
| if STATE["stop_event"]: |
| STATE["stop_event"].set() |
| server.shutdown() |
|
|
| signal.signal(signal.SIGTERM, shutdown) |
| signal.signal(signal.SIGINT, shutdown) |
|
|
| register_worker() |
|
|
| heartbeat = threading.Thread( |
| target=heartbeat_loop, |
| args=(args.heartbeat_interval,), |
| daemon=True |
| ) |
| heartbeat.start() |
|
|
| global server |
| server = ThreadingHTTPServer((args.host, args.port), WorkerHandler) |
| log(f"Worker HTTP démarré sur {args.host}:{args.port}") |
| log(f"Worker name: {STATE['worker_name']}") |
| log("Le serveur Space doit utiliser l'URL ngrok enregistrée dans l'interface.") |
| server.serve_forever() |
|
|
|
|
| if __name__ == "__main__": |
| main() |