Files
rpa_vision_v3/core/gpu/device_policy.py
Dom 0e215da842 feat(p1g): device policy GPU/CPU paramétrable pour la cascade vision
resolve_device(auto/cuda/cpu) avec garde-fou VRAM et fallback CPU propre.
Bascule EasyOCR/SoM/docTR sur GPU si VRAM libre, rollback env sans toucher au code.

- core/gpu/device_policy.py (nouveau) : resolve_device + garde-fou VRAM (max_total_gb)
- core/detection/som_engine.py, core/llm/ocr_extractor.py,
  agent_v0/server_v1/resolve_engine.py : câblage device auto (35 lignes)
- tests/unit/test_device_policy.py : 15 tests (verts venv réel)

Rollback sans toucher au code : RPA_VISION_DEVICE=cpu (force CPU global) / RPA_EASYOCR_GPU=0.
Bench GPU réel (latence) + activation large après verdict Qwen. QG Qwen deja valide sur le patch.
Mergé depuis worktree agent-a4f390f410e00ad7c (base 5b2afa362), 3 fichiers cibles non modifiés
dans le principal (zéro écrasement), dry-run apply propre.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:20:52 +02:00

150 lines
4.9 KiB
Python

"""Résolution de device paramétrable (auto/cuda/cpu) avec garde-fou VRAM.
Permet de basculer les étages CPU-par-défaut de la cascade vision (OCR docTR,
EasyOCR, YOLO/SoM) vers le GPU local **quand la VRAM est libre**, SANS jamais
hardcoder cuda. La politique anti-concurrence VRAM (tout sur CPU) datait d'une
époque où les VLM tournaient sur la RTX 5070 locale ; ils tournent désormais
sur un DGX distant (tunnel SSH `:11434`), libérant ~9 Go localement.
Logique de garde-fou inspirée de `core/embedding/clip_embedder.py` (lignes
~65-82) : `torch.cuda.is_available()` + `torch.cuda.mem_get_info()`.
Contraintes :
- JAMAIS de hardcode cuda ;
- aucun appel réseau ;
- import-safe : aucun chargement de modèle, aucune allocation GPU à l'import ;
- fallback CPU propre partout (jamais de crash si pas de GPU).
Override global : variable d'environnement `RPA_VISION_DEVICE` ∈ {cpu, cuda, auto}.
"""
from __future__ import annotations
import logging
import os
from typing import Optional
import torch
logger = logging.getLogger(__name__)
_GB = 1024 ** 3
# Valeurs reconnues pour l'argument `requested` et l'override env.
_VALID = {"cpu", "cuda", "auto"}
# Garde-fous par défaut (Go).
DEFAULT_MIN_FREE_GB = 2.0 # VRAM libre minimale pour autoriser cuda
DEFAULT_MAX_TOTAL_GB = 6.0 # plafond d'usage VRAM total après bascule
def _env_override() -> Optional[str]:
"""Lit l'override `RPA_VISION_DEVICE` s'il est présent et valide.
Retourne None si absent ou invalide (on retombe alors sur `requested`).
"""
raw = os.getenv("RPA_VISION_DEVICE", "").strip().lower()
if not raw:
return None
if raw in _VALID:
return raw
logger.warning(
"RPA_VISION_DEVICE='%s' invalide (attendu cpu/cuda/auto) — ignoré",
raw,
)
return None
def _cuda_available() -> bool:
"""`torch.cuda.is_available()` protégé contre toute exception driver."""
try:
return bool(torch.cuda.is_available())
except Exception as e: # pragma: no cover - dépend du driver
logger.debug("torch.cuda.is_available a levé : %s — CPU", e)
return False
def _free_total_gb() -> Optional[tuple[float, float]]:
"""VRAM (libre, totale) en Go via mem_get_info, ou None si indisponible."""
try:
free_bytes, total_bytes = torch.cuda.mem_get_info()
return free_bytes / _GB, total_bytes / _GB
except Exception as e: # pragma: no cover - dépend du driver
logger.debug("torch.cuda.mem_get_info a levé : %s", e)
return None
def resolve_device(
requested: str = "auto",
min_free_gb: float = DEFAULT_MIN_FREE_GB,
max_total_gb: float = DEFAULT_MAX_TOTAL_GB,
) -> str:
"""Résout le device effectif ("cuda" ou "cpu") selon la politique VRAM.
Args:
requested: "cpu", "cuda" ou "auto" (défaut). L'override env
`RPA_VISION_DEVICE` prime sur cet argument s'il est présent/valide.
min_free_gb: VRAM libre minimale (Go) pour autoriser cuda en mode auto.
max_total_gb: plafond d'usage VRAM total (Go). Si basculer cuda ferait
dépasser ce plafond (used = total - free), on reste CPU. Garde-fou
contre la saturation quand d'autres process occupent déjà le GPU.
Returns:
"cuda" ou "cpu". Toujours "cpu" en cas de doute (fallback propre).
Politique :
- "cpu""cpu" ;
- "cuda""cuda" si cuda dispo, sinon "cpu" (fallback loggé) ;
- "auto""cuda" si cuda dispo ET free ≥ min_free_gb ET
used ≤ max_total_gb, sinon "cpu".
"""
effective = _env_override() or (requested or "auto").strip().lower()
if effective not in _VALID:
logger.warning(
"device demandé '%s' invalide (attendu cpu/cuda/auto) — auto",
effective,
)
effective = "auto"
if effective == "cpu":
return "cpu"
if not _cuda_available():
if effective == "cuda":
logger.info("device=cuda demandé mais CUDA indisponible — fallback CPU")
return "cpu"
if effective == "cuda":
# Demande explicite : on respecte sans appliquer le garde-fou VRAM
# (l'appelant assume). CUDA est dispo → cuda.
return "cuda"
# effective == "auto" : garde-fou VRAM.
mem = _free_total_gb()
if mem is None:
logger.info("auto: mem_get_info indisponible — CPU par prudence")
return "cpu"
free_gb, total_gb = mem
used_gb = total_gb - free_gb
if free_gb < min_free_gb:
logger.info(
"auto: VRAM libre %.1f Go < seuil %.1f Go — CPU",
free_gb, min_free_gb,
)
return "cpu"
if used_gb > max_total_gb:
logger.info(
"auto: usage VRAM %.1f Go > plafond %.1f Go — CPU",
used_gb, max_total_gb,
)
return "cpu"
logger.info(
"auto: VRAM libre %.1f Go (usage %.1f/%.1f Go) — CUDA",
free_gb, used_gb, total_gb,
)
return "cuda"