Files
imagefind/backend/imagefind/accelerator.py
T

607 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import logging
import os
import time
from datetime import UTC, datetime, timedelta
from pathlib import Path
from threading import Lock
from .config import Settings
logger = logging.getLogger(__name__)
COMPONENTS = ("visual", "ocr", "faces", "audio")
GPU_MODES = {
"low": {"hint": "LATENCY", "streams": 1, "batch_size": 1},
"balanced": {"hint": "THROUGHPUT", "streams": 2, "batch_size": 2},
"throughput": {"hint": "THROUGHPUT", "streams": 3, "batch_size": 4},
}
AUDIO_CIRCUIT_FAILURES = 3
AUDIO_CIRCUIT_COOLDOWN_SECONDS = 600
class AcceleratorService:
"""Detect and track the OpenVINO device used by each AI component.
ImageFind uses explicit GPU compilation instead of OpenVINO AUTO so the
settings page can truthfully report where inference ran. A failed GPU
component is pinned to a bounded CPU fallback until it is reset.
"""
def __init__(self, settings: Settings):
self.settings = settings
self._lock = Lock()
self._runtime_gpu_mode: str | None = None
self._resource_pressure_samples = 0
self._resource_recovery_samples = 0
self._gpu_busy_previous: dict[str, tuple[int, float]] = {}
self.openvino = False
self.devices: list[str] = []
self.device_names: dict[str, str] = {}
self.gpu_device: str | None = None
self.render_nodes: list[str] = []
self.render_accessible = False
self.unavailable_reason = ""
self._components = {
name: {
"state": "not_loaded",
"device": None,
"requested_device": None,
"actual_device": None,
"execution_devices": [],
"failure_stage": None,
"fallback_reason": None,
"fallback_scope": None,
"circuit_state": "closed",
"failure_count": 0,
"retry_at": None,
"last_verified_at": None,
}
for name in COMPONENTS
}
self.refresh()
def refresh(self) -> None:
nodes = sorted(Path("/dev/dri").glob("renderD*"))
self.render_nodes = [str(path) for path in nodes]
self.render_accessible = any(os.access(path, os.R_OK | os.W_OK) for path in nodes)
try:
import openvino as ov
core = ov.Core()
devices = list(core.available_devices)
names: dict[str, str] = {}
for device in devices:
try:
names[device] = str(core.get_property(device, "FULL_DEVICE_NAME"))
except Exception:
names[device] = device
self.openvino = True
self.devices = devices
self.device_names = names
self.gpu_device = next((device for device in devices if device.upper().startswith("GPU")), None)
except ModuleNotFoundError:
logger.info("OpenVINO runtime is not installed; AI acceleration will use CPU fallbacks")
self.openvino = False
self.devices = []
self.device_names = {}
self.gpu_device = None
except Exception:
logger.warning("OpenVINO device discovery failed", exc_info=True)
self.openvino = False
self.devices = []
self.device_names = {}
self.gpu_device = None
if self.gpu_device:
self.unavailable_reason = ""
elif not self.openvino:
self.unavailable_reason = "OpenVINO 运行时不可用"
elif not nodes:
self.unavailable_reason = "未检测到 Intel render 设备"
elif not self.render_accessible:
self.unavailable_reason = "Intel render 设备权限不足"
else:
self.unavailable_reason = "OpenVINO 未发现 Intel GPU,请检查核显驱动"
logger.info(
"AI accelerator devices=%s gpu=%s render_accessible=%s",
self.devices,
self.gpu_device or "none",
self.render_accessible,
)
def device_for(self, component: str) -> str:
with self._lock:
state = self._components[component]
if state["state"] == "fallback":
retry_at = state.get("retry_at")
if component == "audio" and retry_at and self.gpu_device:
try:
retry_due = datetime.fromisoformat(str(retry_at)) <= datetime.now(UTC)
except ValueError:
retry_due = True
if retry_due:
state.update(
state="probing_gpu",
device=self.gpu_device,
requested_device=self.gpu_device,
actual_device=None,
execution_devices=[],
fallback_scope="component",
circuit_state="half_open",
retry_at=None,
)
return self.gpu_device
return "CPU"
return self.gpu_device or "CPU"
def ov_config(self, device: str) -> dict[str, object]:
if device.upper().startswith("CPU"):
return {
"INFERENCE_NUM_THREADS": self.settings.ai_cpu_threads,
"PERFORMANCE_HINT": "LATENCY",
}
cache_dir = self.settings.runtime_dir / "openvino-cache"
cache_dir.mkdir(parents=True, exist_ok=True)
mode = self.effective_gpu_mode()
profile = GPU_MODES[mode]
return {
"PERFORMANCE_HINT": profile["hint"],
"NUM_STREAMS": str(profile["streams"]),
"CACHE_DIR": str(cache_dir),
"INFERENCE_PRECISION_HINT": "f32",
}
def gpu_profile(self) -> dict[str, object]:
selected = self.settings.ai_gpu_mode if self.settings.ai_gpu_mode in GPU_MODES else "balanced"
mode = self.effective_gpu_mode()
return {"mode": mode, "selected_mode": selected, "degraded": mode != selected, **GPU_MODES[mode]}
def effective_gpu_mode(self) -> str:
selected = self.settings.ai_gpu_mode if self.settings.ai_gpu_mode in GPU_MODES else "balanced"
with self._lock:
return self._runtime_gpu_mode or selected
def reset_gpu_profile(self) -> None:
with self._lock:
self._runtime_gpu_mode = None
self._resource_pressure_samples = 0
self._resource_recovery_samples = 0
def apply_resource_pressure(self, pressured: bool) -> None:
"""Temporarily reduce GPU concurrency under sustained host pressure."""
with self._lock:
if pressured:
self._resource_pressure_samples += 1
self._resource_recovery_samples = 0
if self._resource_pressure_samples >= 3:
selected = self.settings.ai_gpu_mode if self.settings.ai_gpu_mode in GPU_MODES else "balanced"
self._runtime_gpu_mode = "low" if selected in {"balanced", "throughput"} else selected
else:
self._resource_pressure_samples = 0
self._resource_recovery_samples += 1
if self._resource_recovery_samples >= 5:
self._runtime_gpu_mode = None
self._resource_recovery_samples = 0
def hardware_metrics(self) -> dict:
"""Collect Intel DRM engine utilization without inventing VRAM data."""
now = time.monotonic()
engines: dict[str, float | None] = {}
busy_files = sorted(Path("/sys/class/drm").glob("card*/engine/*/busy"))
for path in busy_files:
try:
value = int(path.read_text(encoding="utf-8").strip())
except (OSError, ValueError):
continue
key = f"{path.parents[2].name}/{path.parent.name}"
previous = self._gpu_busy_previous.get(str(path))
utilization = None
if previous is not None and now > previous[1] and value >= previous[0]:
utilization = max(0.0, min(100.0, (value - previous[0]) / ((now - previous[1]) * 1_000_000_000) * 100))
self._gpu_busy_previous[str(path)] = (value, now)
engines[key] = round(utilization, 1) if utilization is not None else None
sampled = [value for value in engines.values() if value is not None]
utilization = max(sampled) if sampled else None
memory_total = memory_used = None
for card in sorted(Path("/sys/class/drm").glob("card*/device")):
total_path = card / "mem_info_vram_total"
used_path = card / "mem_info_vram_used"
try:
total_value = int(total_path.read_text(encoding="utf-8").strip())
used_value = int(used_path.read_text(encoding="utf-8").strip())
except (OSError, ValueError):
continue
memory_total = max(0, total_value)
memory_used = max(0, used_value)
break
supported = bool(engines)
reason = None
if not supported:
reason = (
"Intel DRM 未提供 engine busy 指标"
if self.gpu_device
else self.unavailable_reason or "未检测到可用 GPU"
)
return {
"supported": supported,
"utilization_percent": utilization,
"engines": engines,
"memory_supported": memory_total is not None,
"memory_total_bytes": memory_total,
"memory_used_bytes": memory_used,
"collector": "drm_sysfs" if supported else None,
"reason": reason,
"collected_at": datetime.now(UTC).isoformat(),
}
def _degrade_gpu_profile(self) -> None:
order = ("low", "balanced", "throughput")
current = self.effective_gpu_mode()
index = order.index(current)
if index <= 0:
return
with self._lock:
self._runtime_gpu_mode = order[index - 1]
logger.warning("GPU profile degraded from %s to %s after inference failure", current, order[index - 1])
@staticmethod
def _now() -> str:
return datetime.now(UTC).isoformat()
@staticmethod
def _normalise_execution_devices(devices) -> list[str]:
if devices is None:
return []
if isinstance(devices, str):
values = [devices]
else:
try:
values = list(devices)
except TypeError:
values = [devices]
result = []
for value in values:
name = str(value).strip()
if name and name not in result:
result.append(name)
return result
@classmethod
def execution_devices_from(cls, *roots) -> list[str]:
"""Read EXECUTION_DEVICES from OpenVINO/Optimum model wrappers."""
devices: list[str] = []
seen: set[int] = set()
pending = [root for root in roots if root is not None]
attribute_names = (
"model",
"auto_model",
"compiled_model",
"request",
"encoder",
"decoder",
"decoder_with_past",
)
while pending:
value = pending.pop()
identity = id(value)
if identity in seen:
continue
seen.add(identity)
try:
reported = value.get_property("EXECUTION_DEVICES")
# OpenVINO plugins do not expose exactly the same property set.
# In particular, some Intel GPU driver/runtime combinations raise
# ValueError rather than RuntimeError for EXECUTION_DEVICES even
# though the compiled request itself is valid. Treat that branch
# as non-reporting and continue inspecting the other wrappers.
except (AttributeError, RuntimeError, TypeError, ValueError):
reported = None
for device in cls._normalise_execution_devices(reported):
if device not in devices:
devices.append(device)
try:
compiled = value.get_compiled_model()
except (AttributeError, RuntimeError, TypeError, ValueError):
compiled = None
if compiled is not None:
pending.append(compiled)
for name in attribute_names:
try:
child = getattr(value, name)
except (AttributeError, RuntimeError, ValueError):
continue
if child is not None and child is not value:
pending.append(child)
try:
modules = list(value.children())
except (AttributeError, RuntimeError, TypeError, ValueError):
modules = []
pending.extend(modules)
return devices
def mark_ready(self, component: str, device: str, execution_devices=None) -> None:
actual_devices = self._normalise_execution_devices(execution_devices) or [device]
actual = " / ".join(actual_devices)
with self._lock:
prior_reason = self._components[component].get("fallback_reason")
self._components[component] = {
"state": "fallback" if prior_reason and device == "CPU" else "ready",
"device": actual,
"requested_device": device,
"actual_device": actual,
"execution_devices": actual_devices,
"failure_stage": self._components[component].get("failure_stage") if device == "CPU" else None,
"fallback_reason": prior_reason if device == "CPU" else None,
"fallback_scope": self._components[component].get("fallback_scope") if device == "CPU" else None,
"circuit_state": self._components[component].get("circuit_state", "closed"),
"failure_count": int(self._components[component].get("failure_count") or 0),
"retry_at": self._components[component].get("retry_at"),
"last_verified_at": self._now(),
}
def mark_inference_success(self, component: str, device: str, execution_devices=None) -> None:
"""Close a transient circuit only after inference, not merely model compilation."""
actual_devices = self._normalise_execution_devices(execution_devices) or [device]
actual = " / ".join(actual_devices)
with self._lock:
self._components[component] = {
"state": "ready",
"device": actual,
"requested_device": device,
"actual_device": actual,
"execution_devices": actual_devices,
"failure_stage": None,
"fallback_reason": None,
"fallback_scope": None,
"circuit_state": "closed",
"failure_count": 0,
"retry_at": None,
"last_verified_at": self._now(),
}
def mark_verifying_cpu(
self,
component: str,
reason: str = "GPU 未返回文字,正在使用 CPU 复核",
*,
stage: str = "empty_result",
) -> None:
"""Expose a bounded CPU cross-check without pinning the component to CPU.
Empty Whisper output can be either valid no-speech content or a GPU
generation defect. The verifier is deliberately transient: callers
still get the preferred GPU from :meth:`device_for` until a CPU sample
proves that the GPU result was wrong.
"""
with self._lock:
self._components[component] = {
"state": "verifying_cpu",
"device": "CPU",
"requested_device": self.gpu_device,
"actual_device": "CPU",
"execution_devices": ["CPU"],
"failure_stage": stage,
"fallback_reason": reason[:160],
"fallback_scope": "job",
"circuit_state": self._components[component].get("circuit_state", "closed"),
"failure_count": int(self._components[component].get("failure_count") or 0),
"retry_at": self._components[component].get("retry_at"),
"last_verified_at": self._now(),
}
def record_transient_failure(
self,
component: str,
reason: BaseException | str,
*,
stage: str = "inference",
) -> bool:
"""Record one task-local device failure and open the audio circuit after a threshold."""
if component != "audio":
return self.fall_back(component, reason, stage=stage)
detail = type(reason).__name__ if isinstance(reason, BaseException) else str(reason).strip()[:120]
detail = detail or "未知错误"
with self._lock:
current = self._components[component]
failures = int(current.get("failure_count") or 0) + 1
if failures < AUDIO_CIRCUIT_FAILURES:
current.update(
state="job_fallback",
device="CPU",
requested_device=self.gpu_device,
actual_device="CPU",
execution_devices=["CPU"],
failure_stage=stage,
fallback_reason=f"当前任务 GPU 推理失败,使用 CPU({detail}",
fallback_scope="job",
circuit_state="closed",
failure_count=failures,
retry_at=None,
last_verified_at=self._now(),
)
logger.warning(
"AI component %s task-local GPU failure %s/%s: %s",
component,
failures,
AUDIO_CIRCUIT_FAILURES,
detail,
)
return False
retry_at = datetime.now(UTC) + timedelta(seconds=AUDIO_CIRCUIT_COOLDOWN_SECONDS)
current.update(
state="fallback",
device="CPU",
requested_device=self.gpu_device,
actual_device="CPU",
execution_devices=["CPU"],
failure_stage=stage,
fallback_reason=f"GPU 连续推理失败,暂时回退 CPU({detail}",
fallback_scope="component",
circuit_state="open",
failure_count=failures,
retry_at=retry_at.isoformat(),
last_verified_at=self._now(),
)
logger.warning("AI component %s circuit opened after %s failures: %s", component, failures, detail)
return True
def fall_back(self, component: str, reason: BaseException | str, *, stage: str = "inference") -> bool:
"""Pin one GPU component to CPU and report whether a retry is useful."""
with self._lock:
current = self._components[component]
# A temporary CPU verification reports device=CPU as well, but it
# must still be promotable to a real, persistent fallback when the
# comparison sample contains speech that GPU failed to return.
if current.get("state") == "fallback":
return False
if isinstance(reason, BaseException):
detail = f"{type(reason).__name__}"
else:
detail = str(reason).strip()[:120] or "未知错误"
current.update(
state="fallback",
device="CPU",
requested_device=self.gpu_device,
actual_device="CPU",
execution_devices=["CPU"],
failure_stage=stage,
fallback_reason=f"GPU 推理失败,已回退 CPU{detail}",
fallback_scope="component",
circuit_state="open" if component == "audio" else "closed",
failure_count=max(1, int(current.get("failure_count") or 0)),
retry_at=(
(datetime.now(UTC) + timedelta(seconds=AUDIO_CIRCUIT_COOLDOWN_SECONDS)).isoformat()
if component == "audio"
else None
),
last_verified_at=self._now(),
)
if stage in {"encoder_compile", "decoder_compile", "inference", "inference_stall"}:
self._degrade_gpu_profile()
logger.warning("AI component %s fell back from GPU to CPU: %s", component, detail)
return True
@staticmethod
def is_device_error(reason: BaseException) -> bool:
"""Only retry errors that plausibly originate from OpenVINO device compilation/inference."""
module = type(reason).__module__.lower()
name = type(reason).__name__.lower()
text = str(reason).lower()
if "huggingface" in module or "hfvalidation" in name or name == "modelunavailable":
return False
non_device = ("repo id", "repository id", "modules.json", "config.json", "local path", "not a valid model")
if any(value in text for value in non_device):
return False
# Python bindings for Intel GPU compilation and property discovery can
# surface plugin failures as ValueError. The same model is still worth
# retrying on CPU; Hugging Face/configuration ValueErrors were excluded
# above so genuine model layout problems remain visible.
if isinstance(reason, ValueError):
return True
device_markers = (
"openvino",
"gpu",
"device",
"compile_model",
"cldnn",
"level zero",
"ze_result",
"intel",
)
return "openvino" in module or any(value in text for value in device_markers)
def mark_unavailable(
self,
component: str,
reason: BaseException | str,
*,
stage: str = "inference",
) -> None:
detail = type(reason).__name__ if isinstance(reason, BaseException) else str(reason).strip()[:120]
with self._lock:
self._components[component] = {
"state": "unavailable",
"device": None,
"requested_device": self.gpu_device or "CPU",
"actual_device": None,
"execution_devices": [],
"failure_stage": stage,
"fallback_reason": detail or "模型不可用",
"fallback_scope": "component",
"circuit_state": "open" if component == "audio" else "closed",
"failure_count": int(self._components[component].get("failure_count") or 0),
"retry_at": None,
"last_verified_at": self._now(),
}
def reset(self, component: str) -> None:
with self._lock:
previous = self._components[component]
self._components[component] = {
"state": "not_loaded",
"device": None,
# Releasing a model only means it is no longer resident in
# memory. Keep the last verified device as observability
# history so a completed stage does not immediately revert
# to the misleading "never loaded" state in the UI.
"requested_device": previous.get("requested_device"),
"actual_device": previous.get("actual_device"),
"execution_devices": list(previous.get("execution_devices") or []),
"failure_stage": None,
"fallback_reason": None,
"fallback_scope": None,
"circuit_state": "closed",
"failure_count": 0,
"retry_at": None,
"last_verified_at": previous.get("last_verified_at"),
}
def merge_worker_status(self, status: dict) -> None:
"""Mirror observable state reported by the isolated inference worker."""
components = status.get("components")
if not isinstance(components, dict):
return
with self._lock:
for name in COMPONENTS:
value = components.get(name)
if isinstance(value, dict):
self._components[name] = dict(value)
def status(self) -> dict:
with self._lock:
components = {name: dict(value) for name, value in self._components.items()}
active = sorted({value["device"] for value in components.values() if value.get("device")})
if active:
selected = " / ".join(active)
elif self.gpu_device:
selected = "Intel GPU 优先(首次推理时加载)"
else:
selected = "CPU(未检测到可用 Intel GPU"
return {
"policy": "gpu_preferred",
"openvino": self.openvino,
"devices": list(self.devices),
"device_names": dict(self.device_names),
"selected": selected,
"render_device": {
"available": bool(self.render_nodes),
"accessible": self.render_accessible,
"count": len(self.render_nodes),
},
"unavailable_reason": self.unavailable_reason or None,
"cpu_threads": self.settings.ai_cpu_threads,
"gpu_profile": self.gpu_profile(),
"components": components,
}