mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-28 00:38:57 +03:00
* Migrate Hailo detector key and support hailo device * Fix missing check
475 lines
15 KiB
Python
475 lines
15 KiB
Python
"""Config driven hardware usage collection for stats."""
|
|
|
|
import logging
|
|
import os
|
|
import time
|
|
from collections.abc import Callable
|
|
from concurrent.futures import Future, ThreadPoolExecutor, wait
|
|
from dataclasses import dataclass, field
|
|
from functools import cache, partial
|
|
from glob import glob
|
|
from typing import Any
|
|
|
|
from frigate.config import FrigateConfig
|
|
from frigate.config.camera.updater import (
|
|
CameraConfigUpdateEnum,
|
|
CameraConfigUpdateSubscriber,
|
|
)
|
|
from frigate.config.classification import SemanticSearchModelEnum
|
|
from frigate.const import FFMPEG_HWACCEL_AMF, FFMPEG_HWACCEL_VULKAN
|
|
from frigate.detectors.hardware import DEV_ROOT, hardware_prober
|
|
from frigate.util.services import (
|
|
get_amd_gpu_stats,
|
|
get_axcl_npu_stats,
|
|
get_cpu_stats,
|
|
get_hailo_temps,
|
|
get_intel_gpu_stats,
|
|
get_jetson_stats,
|
|
get_nvidia_gpu_stats,
|
|
get_openvino_npu_stats,
|
|
get_rockchip_gpu_stats,
|
|
get_rockchip_npu_stats,
|
|
is_vaapi_amd_driver,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
HARDWARE_ERROR_COOLDOWN_SECONDS = 3600
|
|
POLL_TIMEOUT_SECONDS = 10
|
|
|
|
# hardware names polled here, in the order used to resolve a generic GPU demand
|
|
GPU_HARDWARE_KEYS = {
|
|
"onnx:nvidia": "nvidia",
|
|
"tensorrt": "jetson",
|
|
"onnx:amd": "amd_gpu",
|
|
"openvino:GPU": "intel_gpu",
|
|
"rknn": "rockchip",
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class HardwarePollResult:
|
|
"""Usage entries from one hardware poll, split by stats bucket."""
|
|
|
|
gpu: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
npu: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
ok: bool = True
|
|
|
|
|
|
@cache
|
|
def _is_amd_vaapi() -> bool:
|
|
"""Cached vaapi driver check, the driver cannot change at runtime."""
|
|
return is_vaapi_amd_driver()
|
|
|
|
|
|
def _present_hardware() -> set[str]:
|
|
return {hardware.key for hardware in hardware_prober.probe()}
|
|
|
|
|
|
def _gpu_device_nodes_exist(name: str) -> bool:
|
|
"""Whether the GPU's device nodes are visible to this container.
|
|
|
|
The prober reads /proc and /sys, which show the host kernel's hardware
|
|
even when a device was not passed into the container, so a GPU is only
|
|
usable when its device nodes exist too.
|
|
"""
|
|
if name == "nvidia":
|
|
return bool(glob(f"{DEV_ROOT}/nvidia*"))
|
|
elif name in ("amd_gpu", "intel_gpu"):
|
|
return bool(glob(f"{DEV_ROOT}/dri/*"))
|
|
|
|
return True
|
|
|
|
|
|
def _present_gpu() -> str | None:
|
|
"""The hardware name of the first usable GPU found on this system, if any."""
|
|
present = _present_hardware()
|
|
|
|
for key, name in GPU_HARDWARE_KEYS.items():
|
|
if key in present and _gpu_device_nodes_exist(name):
|
|
return name
|
|
|
|
return None
|
|
|
|
|
|
def _poll_nvidia(config: FrigateConfig) -> HardwarePollResult:
|
|
nvidia_usage = get_nvidia_gpu_stats()
|
|
|
|
if not nvidia_usage:
|
|
return HardwarePollResult(
|
|
gpu={"nvidia-gpu": {"vendor": "nvidia", "gpu": "", "mem": ""}}, ok=False
|
|
)
|
|
|
|
gpu: dict[str, dict[str, Any]] = {}
|
|
|
|
for entry in nvidia_usage.values():
|
|
gpu[entry["name"]] = {
|
|
"vendor": "nvidia",
|
|
"gpu": str(round(float(entry["gpu"]), 2)) + "%",
|
|
"mem": str(round(float(entry["mem"]), 2)) + "%",
|
|
"enc": str(round(float(entry["enc"]), 2)) + "%",
|
|
"dec": str(round(float(entry["dec"]), 2)) + "%",
|
|
"temp": str(entry["temp"]),
|
|
}
|
|
|
|
return HardwarePollResult(gpu=gpu)
|
|
|
|
|
|
def _poll_jetson(config: FrigateConfig) -> HardwarePollResult:
|
|
jetson_usage = get_jetson_stats()
|
|
|
|
if not jetson_usage:
|
|
return HardwarePollResult(
|
|
gpu={"jetson-gpu": {"vendor": "nvidia", "gpu": "", "mem": ""}}, ok=False
|
|
)
|
|
|
|
return HardwarePollResult(gpu={"jetson-gpu": {"vendor": "nvidia", **jetson_usage}})
|
|
|
|
|
|
def _poll_intel_gpu(config: FrigateConfig) -> HardwarePollResult:
|
|
intel_usage = get_intel_gpu_stats(config.telemetry.stats.intel_gpu_device)
|
|
|
|
if not intel_usage:
|
|
return HardwarePollResult(
|
|
gpu={"intel-gpu": {"vendor": "intel", "gpu": "", "mem": ""}}, ok=False
|
|
)
|
|
|
|
gpu: dict[str, dict[str, Any]] = {}
|
|
|
|
for entry in intel_usage.values():
|
|
name = entry.pop("name")
|
|
gpu[name] = entry
|
|
|
|
return HardwarePollResult(gpu=gpu)
|
|
|
|
|
|
def _poll_amd_gpu(config: FrigateConfig) -> HardwarePollResult:
|
|
amd_usage = get_amd_gpu_stats()
|
|
|
|
if not amd_usage:
|
|
return HardwarePollResult(
|
|
gpu={"amd-vaapi": {"vendor": "amd", "gpu": "", "mem": ""}}, ok=False
|
|
)
|
|
|
|
return HardwarePollResult(gpu={"amd-vaapi": {"vendor": "amd", **amd_usage}})
|
|
|
|
|
|
def _poll_rockchip(config: FrigateConfig) -> HardwarePollResult:
|
|
result = HardwarePollResult()
|
|
rga_usage = get_rockchip_gpu_stats()
|
|
|
|
if rga_usage:
|
|
result.gpu["rockchip"] = {"vendor": "rockchip", **rga_usage}
|
|
|
|
npu_usage = get_rockchip_npu_stats()
|
|
|
|
if npu_usage:
|
|
result.npu["rockchip"] = npu_usage
|
|
|
|
result.ok = bool(rga_usage or npu_usage)
|
|
return result
|
|
|
|
|
|
def _poll_rpi(config: FrigateConfig) -> HardwarePollResult:
|
|
# RPi v4l2m2m is currently not able to get usage stats
|
|
return HardwarePollResult(
|
|
gpu={"rpi-v4l2m2m": {"vendor": "rpi", "gpu": "", "mem": ""}}
|
|
)
|
|
|
|
|
|
def _poll_intel_npu(config: FrigateConfig) -> HardwarePollResult:
|
|
npu_usage = get_openvino_npu_stats()
|
|
|
|
if not npu_usage:
|
|
return HardwarePollResult(ok=False)
|
|
|
|
return HardwarePollResult(npu={"openvino": npu_usage})
|
|
|
|
|
|
def _poll_axengine(config: FrigateConfig) -> HardwarePollResult:
|
|
npu_usage = get_axcl_npu_stats()
|
|
|
|
if not npu_usage:
|
|
return HardwarePollResult(ok=False)
|
|
|
|
return HardwarePollResult(npu={"axengine": npu_usage})
|
|
|
|
|
|
POLLERS: dict[str, Callable[[FrigateConfig], HardwarePollResult]] = {
|
|
"nvidia": _poll_nvidia,
|
|
"jetson": _poll_jetson,
|
|
"intel_gpu": _poll_intel_gpu,
|
|
"amd_gpu": _poll_amd_gpu,
|
|
"rockchip": _poll_rockchip,
|
|
"rpi": _poll_rpi,
|
|
"intel_npu": _poll_intel_npu,
|
|
"axengine": _poll_axengine,
|
|
}
|
|
|
|
|
|
def _hwaccel_hardware(args: str) -> str | None:
|
|
"""Map an ffmpeg hwaccel arg string (preset or raw args) to a hardware name."""
|
|
if "cuvid" in args or "nvidia" in args:
|
|
return "nvidia"
|
|
elif "nvmpi" in args or "jetson" in args:
|
|
return "jetson"
|
|
elif "qsv" in args:
|
|
return "intel_gpu"
|
|
elif FFMPEG_HWACCEL_AMF in args or "amf" in args:
|
|
return "amd_gpu"
|
|
elif "vaapi" in args:
|
|
return "amd_gpu" if _is_amd_vaapi() else "intel_gpu"
|
|
elif "preset-rk" in args or "rkmpp" in args:
|
|
return "rockchip"
|
|
elif "v4l2m2m" in args or "rpi" in args:
|
|
return "rpi"
|
|
elif FFMPEG_HWACCEL_VULKAN in args or "vulkan" in args:
|
|
# vulkan is vendor neutral, attribute it to whichever GPU is present
|
|
return _present_gpu()
|
|
|
|
return None
|
|
|
|
|
|
class HardwareStats:
|
|
"""Catalogs and polls all hardware relevant to the config."""
|
|
|
|
def __init__(self, config: FrigateConfig) -> None:
|
|
self.config = config
|
|
# hardware name -> bound poll function; the catalog of what to monitor
|
|
self._monitored: dict[str, Callable[[], HardwarePollResult]] = {}
|
|
self._errors: dict[str, float] = {}
|
|
self._executor = ThreadPoolExecutor(
|
|
max_workers=8, thread_name_prefix="hw_stats"
|
|
)
|
|
self._config_subscriber = CameraConfigUpdateSubscriber(
|
|
config,
|
|
config.cameras,
|
|
[
|
|
CameraConfigUpdateEnum.add,
|
|
CameraConfigUpdateEnum.remove,
|
|
CameraConfigUpdateEnum.ffmpeg,
|
|
CameraConfigUpdateEnum.audio_transcription,
|
|
CameraConfigUpdateEnum.semantic_search,
|
|
CameraConfigUpdateEnum.face_recognition,
|
|
CameraConfigUpdateEnum.lpr,
|
|
],
|
|
)
|
|
self.update_config()
|
|
|
|
def update_config(self) -> None:
|
|
"""Recalculate all hardware that needs to be monitored from the config."""
|
|
names = self._scan_ffmpeg() | self._scan_detectors() | self._scan_enrichments()
|
|
monitored: dict[str, Callable[[], HardwarePollResult]] = {}
|
|
|
|
for name in sorted(names):
|
|
if name == "intel_gpu" and not self.config.telemetry.stats.intel_gpu_stats:
|
|
continue
|
|
|
|
if name == "amd_gpu" and not self.config.telemetry.stats.amd_gpu_stats:
|
|
continue
|
|
|
|
monitored[name] = partial(POLLERS[name], self.config)
|
|
|
|
self._monitored = monitored
|
|
logger.debug("Monitoring hardware for stats: %s", list(monitored))
|
|
|
|
def update_stats(self, all_stats: dict[str, Any]) -> None:
|
|
"""Poll the monitored hardware concurrently and fill usage stats."""
|
|
if self._config_subscriber.check_for_updates():
|
|
self.update_config()
|
|
|
|
now = time.monotonic()
|
|
hardware_futures: dict[Future[HardwarePollResult], str] = {}
|
|
|
|
for name, poll in self._monitored.items():
|
|
last_error = self._errors.get(name)
|
|
|
|
if last_error is not None:
|
|
if now - last_error < HARDWARE_ERROR_COOLDOWN_SECONDS:
|
|
continue
|
|
|
|
self._errors.pop(name, None)
|
|
|
|
hardware_futures[self._executor.submit(poll)] = name
|
|
|
|
cpu_future = self._executor.submit(get_cpu_stats)
|
|
futures: list[Future[Any]] = [*hardware_futures, cpu_future]
|
|
done, _ = wait(futures, timeout=POLL_TIMEOUT_SECONDS)
|
|
|
|
gpu_usages: dict[str, dict[str, Any]] = {}
|
|
npu_usages: dict[str, dict[str, Any]] = {}
|
|
|
|
for future, name in hardware_futures.items():
|
|
if future not in done:
|
|
logger.warning("Timed out collecting %s stats", name)
|
|
self._errors[name] = time.monotonic()
|
|
continue
|
|
|
|
try:
|
|
result = future.result()
|
|
except Exception:
|
|
logger.exception("Failed to collect %s stats", name)
|
|
self._errors[name] = time.monotonic()
|
|
continue
|
|
|
|
gpu_usages.update(result.gpu)
|
|
npu_usages.update(result.npu)
|
|
|
|
if not result.ok:
|
|
self._errors[name] = time.monotonic()
|
|
|
|
if gpu_usages:
|
|
all_stats["gpu_usages"] = gpu_usages
|
|
|
|
if npu_usages:
|
|
all_stats["npu_usages"] = npu_usages
|
|
|
|
if cpu_future in done:
|
|
try:
|
|
cpu_stats = cpu_future.result()
|
|
except Exception:
|
|
logger.exception("Failed to collect cpu stats")
|
|
else:
|
|
if cpu_stats:
|
|
all_stats["cpu_usages"] = cpu_stats
|
|
|
|
def stop(self) -> None:
|
|
self._config_subscriber.stop()
|
|
self._executor.shutdown(wait=False, cancel_futures=True)
|
|
|
|
def _scan_ffmpeg(self) -> set[str]:
|
|
"""Hardware used by camera hwaccel args (presets or raw ffmpeg args)."""
|
|
hwaccel_args: list[str] = []
|
|
|
|
for camera in self.config.cameras.values():
|
|
args = camera.ffmpeg.hwaccel_args
|
|
|
|
if isinstance(args, list):
|
|
args = " ".join(args)
|
|
|
|
if args and args not in hwaccel_args:
|
|
hwaccel_args.append(args)
|
|
|
|
for stream_input in camera.ffmpeg.inputs:
|
|
args = stream_input.hwaccel_args
|
|
|
|
if isinstance(args, list):
|
|
args = " ".join(args)
|
|
|
|
if args and args not in hwaccel_args:
|
|
hwaccel_args.append(args)
|
|
|
|
names: set[str] = set()
|
|
|
|
for args in hwaccel_args:
|
|
name = _hwaccel_hardware(args)
|
|
|
|
if name is not None:
|
|
names.add(name)
|
|
|
|
return names
|
|
|
|
def _scan_detectors(self) -> set[str]:
|
|
"""Hardware used by the configured detection models."""
|
|
names: set[str] = set()
|
|
|
|
for model in self.config.models:
|
|
for spec in self.config.devices_for_model(model):
|
|
if spec.detector == "rknn":
|
|
names.add("rockchip")
|
|
elif spec.detector == "axengine":
|
|
names.add("axengine")
|
|
elif spec.detector == "tensorrt":
|
|
names.add("jetson")
|
|
elif spec.detector == "openvino":
|
|
if spec.device == "NPU":
|
|
names.add("intel_npu")
|
|
elif spec.device is None or spec.device.startswith("GPU"):
|
|
names.add("intel_gpu")
|
|
elif spec.detector == "onnx":
|
|
gpu = _present_gpu()
|
|
|
|
if gpu is not None:
|
|
names.add(gpu)
|
|
|
|
return names
|
|
|
|
def _scan_enrichments(self) -> set[str]:
|
|
"""Hardware used by enabled enrichments, resolved to the present GPU."""
|
|
config = self.config
|
|
names: set[str] = set()
|
|
gpu = _present_gpu()
|
|
|
|
if gpu is not None:
|
|
semantic = config.semantic_search
|
|
|
|
if (
|
|
semantic.enabled
|
|
# GenAI providers run remotely and use no local hardware
|
|
and (
|
|
semantic.model is None
|
|
or isinstance(semantic.model, SemanticSearchModelEnum)
|
|
)
|
|
and (
|
|
semantic.device
|
|
or ("GPU" if semantic.model_size == "large" else "CPU")
|
|
)
|
|
!= "CPU"
|
|
):
|
|
names.add(gpu)
|
|
|
|
if (
|
|
config.face_recognition.enabled
|
|
and (config.face_recognition.device or "GPU") != "CPU"
|
|
):
|
|
names.add(gpu)
|
|
|
|
if config.lpr.enabled and (config.lpr.device or "AUTO") != "CPU":
|
|
names.add(gpu)
|
|
|
|
# audio transcription runs on CUDA only, and the device is global only
|
|
transcription = config.audio_transcription
|
|
transcription_enabled = transcription.enabled or any(
|
|
camera.audio_transcription.enabled for camera in config.cameras.values()
|
|
)
|
|
|
|
if (
|
|
transcription_enabled
|
|
and transcription.device == "GPU"
|
|
and "onnx:nvidia" in _present_hardware()
|
|
and _gpu_device_nodes_exist("nvidia")
|
|
):
|
|
names.add("nvidia")
|
|
|
|
return names
|
|
|
|
|
|
def read_temperature(path: str) -> float | None:
|
|
"""Read a sysfs temperature file, converting millidegrees to degrees.
|
|
|
|
Returns None when the file does not exist.
|
|
"""
|
|
if os.path.isfile(path):
|
|
with open(path) as f:
|
|
line = f.readline().strip()
|
|
return int(line) / 1000
|
|
return None
|
|
|
|
|
|
def get_hardware_temperatures(detector_type: str) -> list[float | None]:
|
|
"""Per unit temperatures for a detector type, in stable device order."""
|
|
if detector_type == "edgetpu":
|
|
# PCIe Corals expose a temperature through the apex driver
|
|
base = "/sys/class/apex/"
|
|
|
|
if os.path.isdir(base):
|
|
return [
|
|
read_temperature(os.path.join(base, apex, "temp"))
|
|
for apex in sorted(os.listdir(base))
|
|
]
|
|
elif detector_type == "hailo":
|
|
hailo_temps = get_hailo_temps()
|
|
return [hailo_temps[name] for name in sorted(hailo_temps.keys())]
|
|
|
|
return []
|