video_monitor/app/utils/GpuInfo.py

466 lines
15 KiB
Python
Raw Normal View History

2026-08-30 22:22:11 +08:00
"""本机显卡信息采集(支持 NVIDIA / Intel / AMDWindows & Linux"""
import json
import os
import platform
import re
import shutil
import subprocess
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
_GPU_CACHE = {"ts": 0.0, "data": []}
_GPU_STATIC_CACHE = {"ts": 0.0, "adapters": []}
_GPU_CACHE_LOCK = threading.Lock()
_GPU_CACHE_TTL = 8.0
_GPU_STATIC_TTL = 120.0
def _byte_to_mb(val):
try:
v = float(val)
except (TypeError, ValueError):
return None
if v <= 0:
return None
return round(v / (1024 * 1024), 1)
def _run_cmd(cmd, timeout=8, shell=False):
try:
flags = 0
if os.name == 'nt' and hasattr(subprocess, 'CREATE_NO_WINDOW'):
flags = subprocess.CREATE_NO_WINDOW
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
shell=shell,
encoding='utf-8',
errors='ignore',
creationflags=flags,
)
if proc.returncode != 0:
return ''
return (proc.stdout or '').strip()
except Exception:
return ''
def _vendor_from_name(name):
n = (name or '').lower()
if 'nvidia' in n or 'geforce' in n or 'quadro' in n or 'tesla' in n or 'rtx' in n or 'gtx' in n:
return 'nvidia'
if 'intel' in n or 'iris' in n or 'uhd' in n:
return 'intel'
if 'amd' in n or 'radeon' in n:
return 'amd'
return 'other'
def _is_virtual_gpu(name):
n = (name or '').lower()
skip = (
'microsoft basic', 'remote desktop', 'virtual display', 'virtual adapter',
'meta virtual', 'spacedesk', 'parsec', 'vmware', 'virtualbox',
'oray', 'idd driver', 'sunlogin', 'toDesk',
)
return any(k in n for k in skip)
def _merge_gpu(existing, new):
"""按 index / 规范化名称合并,保留利用率更高的记录。"""
key = new.get('key') or str(new.get('index', new.get('name', '')))
old = existing.get(key)
if not old:
existing[key] = new
return
for field in ('util_percent', 'mem_used_mb', 'mem_total_mb', 'temperature_c'):
nv = new.get(field)
ov = old.get(field)
if nv is not None and (ov is None or (field == 'util_percent' and nv > ov)):
old[field] = nv
if len(new.get('name') or '') > len(old.get('name') or ''):
old['name'] = new['name']
if new.get('vendor') and new.get('vendor') != 'other':
old['vendor'] = new['vendor']
def _finalize_gpu(item):
util = item.get('util_percent')
mem_used = item.get('mem_used_mb')
mem_total = item.get('mem_total_mb')
mem_rate = None
if mem_used is not None and mem_total and mem_total > 0:
mem_rate = round(mem_used / mem_total, 3)
elif item.get('mem_util_percent') is not None:
mem_rate = round(float(item['mem_util_percent']) / 100, 3)
parts = []
if util is not None:
parts.append('%s%%' % round(float(util), 1))
if mem_used is not None and mem_total:
parts.append('%s / %s' % (_fmt_mb(mem_used), _fmt_mb(mem_total)))
elif mem_total:
parts.append(_fmt_mb(mem_total))
return {
'index': item.get('index', 0),
'name': item.get('name') or 'GPU',
'vendor': item.get('vendor') or 'other',
'util_percent': round(float(util), 1) if util is not None else None,
'mem_used_mb': mem_used,
'mem_total_mb': mem_total,
'mem_used_rate': mem_rate,
'temperature_c': item.get('temperature_c'),
'detail_str': ' / '.join(parts) if parts else '--',
}
def _fmt_mb(v):
if v is None:
return '--'
if v >= 1024:
return '%.2fGB' % (v / 1024)
if float(v).is_integer():
return '%dMB' % int(v)
return '%.1fMB' % float(v)
def _collect_nvidia_gpus():
if not shutil.which('nvidia-smi'):
return []
out = _run_cmd([
'nvidia-smi',
'--query-gpu=index,name,utilization.gpu,utilization.memory,memory.total,memory.used,temperature.gpu',
'--format=csv,noheader,nounits',
])
if not out:
return []
gpus = []
for line in out.splitlines():
line = line.strip()
if not line:
continue
parts = [p.strip() for p in line.split(',')]
if len(parts) < 6:
continue
try:
idx = int(parts[0])
except ValueError:
idx = len(gpus)
name = parts[1]
util_gpu = _safe_float(parts[2])
mem_util = _safe_float(parts[3])
mem_total = _safe_float(parts[4])
mem_used = _safe_float(parts[5])
temp = _safe_float(parts[6]) if len(parts) > 6 else None
gpus.append({
'key': 'nvidia:%s' % idx,
'index': idx,
'name': name,
'vendor': 'nvidia',
'util_percent': util_gpu,
'mem_util_percent': mem_util,
'mem_total_mb': mem_total,
'mem_used_mb': mem_used,
'temperature_c': temp,
})
return gpus
def _safe_float(s):
if s is None:
return None
s = str(s).strip().replace('[N/A]', '').replace('N/A', '')
if not s:
return None
try:
return float(s)
except ValueError:
return None
def _collect_windows_adapters():
"""WMI 读取显卡列表(较快,不含利用率)。"""
now = time.time()
with _GPU_CACHE_LOCK:
if _GPU_STATIC_CACHE['adapters'] and now - _GPU_STATIC_CACHE['ts'] < _GPU_STATIC_TTL:
return list(_GPU_STATIC_CACHE['adapters'])
ps_script = r"""
$ErrorActionPreference = 'SilentlyContinue'
$controllers = Get-CimInstance Win32_VideoController | Where-Object {
$_.Name -and ($_.Name -notmatch 'Microsoft Basic|Remote Desktop|Virtual Display|Virtual Adapter|Meta Virtual|Spacedesk|Parsec|VMware|VirtualBox|Oray|Idd Driver|Sunlogin|ToDesk')
}
$list = @()
$i = 0
foreach ($c in $controllers) {
$name = [string]$c.Name
$vendor = 'other'
if ($name -match 'NVIDIA|GeForce|Quadro|RTX|GTX|Tesla') { $vendor = 'nvidia' }
elseif ($name -match 'Intel|Iris|UHD|Arc') { $vendor = 'intel' }
elseif ($name -match 'AMD|Radeon') { $vendor = 'amd' }
$ramMb = $null
if ($c.AdapterRAM -and [double]$c.AdapterRAM -gt 0) {
$ramMb = [math]::Round([double]$c.AdapterRAM / 1MB, 0)
}
$list += [pscustomobject]@{
index = $i
name = $name
vendor = $vendor
mem_total_mb = $ramMb
}
$i++
}
$list | ConvertTo-Json -Compress
"""
out = _run_cmd(['powershell', '-NoProfile', '-NonInteractive', '-Command', ps_script], timeout=5)
if not out:
return []
try:
data = json.loads(out)
except json.JSONDecodeError:
return []
if isinstance(data, dict):
data = [data]
adapters = []
for item in data:
if not isinstance(item, dict):
continue
name = item.get('name') or ''
if _is_virtual_gpu(name):
continue
adapters.append({
'index': item.get('index', len(adapters)),
'name': name,
'vendor': item.get('vendor') or _vendor_from_name(name),
'mem_total_mb': item.get('mem_total_mb'),
})
with _GPU_CACHE_LOCK:
_GPU_STATIC_CACHE['adapters'] = list(adapters)
_GPU_STATIC_CACHE['ts'] = time.time()
return adapters
def _collect_windows_util():
"""读取 Windows GPU 利用率Get-Counter 较慢,单独调用并限制超时)。"""
ps_script = r"""
$ErrorActionPreference = 'SilentlyContinue'
$utilByPhys = @{}
try {
$samples = (Get-Counter '\GPU Engine(*)\Utilization Percentage' -SampleInterval 1 -MaxSamples 1).CounterSamples
foreach ($s in $samples) {
if ($s.InstanceName -match 'phys_(\d+)') {
$p = $matches[1]
$v = [double]$s.CookedValue
if (-not $utilByPhys.ContainsKey($p) -or $v -gt $utilByPhys[$p]) {
$utilByPhys[$p] = [math]::Round($v, 1)
}
}
}
} catch {}
$utilByPhys | ConvertTo-Json -Compress
"""
out = _run_cmd(['powershell', '-NoProfile', '-NonInteractive', '-Command', ps_script], timeout=4)
if not out:
return {}
try:
util_map = json.loads(out or '{}')
except json.JSONDecodeError:
return {}
return util_map if isinstance(util_map, dict) else {}
def _merge_windows_adapters_util(adapters, util_map):
gpus = []
for item in adapters:
idx = item.get('index', len(gpus))
name = item.get('name') or ''
util = _safe_float(util_map.get(str(idx)))
if util is None:
util = _safe_float(util_map.get(idx))
gpus.append({
'key': 'win:%s:%s' % (idx, _norm_name(name)),
'index': idx,
'name': name,
'vendor': item.get('vendor') or _vendor_from_name(name),
'util_percent': util,
'mem_total_mb': item.get('mem_total_mb'),
'mem_used_mb': None,
})
used_utils = {g.get('util_percent') for g in gpus if g.get('util_percent') is not None}
spare_utils = []
for v in (util_map or {}).values():
fv = _safe_float(v)
if fv is not None and fv not in used_utils:
spare_utils.append(fv)
spare_utils.sort(reverse=True)
for g in gpus:
if g.get('util_percent') is None and spare_utils:
g['util_percent'] = spare_utils.pop(0)
return gpus
def _collect_windows_gpus():
adapters = _collect_windows_adapters()
if not adapters:
return []
util_map = _collect_windows_util()
return _merge_windows_adapters_util(adapters, util_map)
def _norm_name(name):
return re.sub(r'\s+', ' ', (name or '').strip().lower())
def _collect_linux_gpus():
merged = {}
# NVIDIA
for g in _collect_nvidia_gpus():
_merge_gpu(merged, g)
# DRM 卡名称
drm_cards = []
drm_root = '/sys/class/drm'
if os.path.isdir(drm_root):
for entry in sorted(os.listdir(drm_root)):
if not re.match(r'^card\d+$', entry):
continue
card_path = os.path.join(drm_root, entry)
name = _read_first_line(os.path.join(card_path, 'device/vendor'))
prod = _read_first_line(os.path.join(card_path, 'device/device'))
label = entry
try:
for dev in os.listdir(card_path):
if dev.startswith('renderD') or re.match(r'^card\d+-', dev):
pass
except OSError:
pass
vendor_file = _read_first_line(os.path.join(card_path, 'device/vendor'))
device_file = _read_first_line(os.path.join(card_path, 'device/device'))
human = _linux_pci_name(vendor_file, device_file) or entry
idx_match = re.search(r'card(\d+)', entry)
idx = int(idx_match.group(1)) if idx_match else len(drm_cards)
drm_cards.append({'index': idx, 'name': human, 'vendor': _vendor_from_name(human), 'key': 'drm:%s' % entry})
# Intel GPU load (RK/Intel platforms)
intel_load = _read_first_line('/sys/kernel/debug/dri/0/i915_gem_objects') # not util
intel_util = _linux_intel_util()
for card in drm_cards:
util = intel_util if card['vendor'] == 'intel' and intel_util is not None else None
_merge_gpu(merged, {
'key': card['key'],
'index': card['index'],
'name': card['name'],
'vendor': card['vendor'],
'util_percent': util,
})
return list(merged.values())
def _read_first_line(path):
try:
with open(path, 'r', encoding='utf-8', errors='ignore') as f:
return f.read().strip()
except OSError:
return ''
def _linux_pci_name(vendor, device):
vendor_map = {'0x8086': 'Intel', '0x10de': 'NVIDIA', '0x1002': 'AMD'}
v = vendor_map.get(vendor.lower() if vendor else '', '')
if v:
return '%s GPU (%s)' % (v, device or '')
return ''
def _linux_intel_util():
"""尝试读取 Intel iGPU 占用(部分内核提供 dri 调试节点)。"""
for path in (
'/sys/class/drm/card0/device/gt_busy_percent',
'/sys/class/drm/card1/device/gt_busy_percent',
):
val = _read_first_line(path)
if val:
f = _safe_float(val.replace('%', ''))
if f is not None:
return f
return None
def _merge_nvidia_into_windows(merged, nvidia_gpus):
for g in nvidia_gpus:
matched = False
norm = _norm_name(g.get('name'))
for item in merged.values():
if item.get('vendor') == 'nvidia' and (
_norm_name(item.get('name')) in norm or norm in _norm_name(item.get('name'))
):
item.update({
'util_percent': g.get('util_percent', item.get('util_percent')),
'mem_used_mb': g.get('mem_used_mb', item.get('mem_used_mb')),
'mem_total_mb': g.get('mem_total_mb', item.get('mem_total_mb')),
'mem_util_percent': g.get('mem_util_percent'),
'temperature_c': g.get('temperature_c'),
'name': g.get('name') or item.get('name'),
})
matched = True
break
if not matched:
_merge_gpu(merged, g)
def _collect_gpu_info_uncached():
merged = {}
system = platform.system()
if system == 'Windows':
adapters = _collect_windows_adapters()
nvidia_gpus = []
util_map = {}
with ThreadPoolExecutor(max_workers=2) as pool:
f_util = pool.submit(_collect_windows_util)
f_nv = pool.submit(_collect_nvidia_gpus)
try:
util_map = f_util.result(timeout=4.5) or {}
except Exception:
util_map = {}
try:
nvidia_gpus = f_nv.result(timeout=3) or []
except Exception:
nvidia_gpus = []
for g in _merge_windows_adapters_util(adapters, util_map):
_merge_gpu(merged, g)
_merge_nvidia_into_windows(merged, nvidia_gpus)
else:
for g in _collect_linux_gpus():
_merge_gpu(merged, g)
result = [_finalize_gpu(v) for v in merged.values()]
result.sort(key=lambda x: (x.get('index', 0), x.get('name', '')))
for i, g in enumerate(result):
g['index'] = i
return result
def get_gpu_info(force_refresh=False):
"""返回本机所有可用显卡列表(带短时缓存,避免频繁调用 PowerShell"""
now = time.time()
with _GPU_CACHE_LOCK:
if not force_refresh and _GPU_CACHE['data'] and now - _GPU_CACHE['ts'] < _GPU_CACHE_TTL:
return list(_GPU_CACHE['data'])
result = _collect_gpu_info_uncached()
with _GPU_CACHE_LOCK:
_GPU_CACHE['data'] = list(result)
_GPU_CACHE['ts'] = time.time()
return result