Coverage for src/lilbee/providers/fleet/gpu_backends/intel.py: 100%

142 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-14 11:46 +0000

1"""Intel GPU utilization from the first available source. 

2 

3Intel exposes GPU activity three different ways depending on the GPU class, 

4kernel, and privileges. We try them in order and take the first that reports: 

5 

61. ``xpu-smi`` (Intel XPU Manager) -- Data Center GPU Max/Flex and Arc only. 

7 Reads a ``device_level`` array of {metrics_type, value} objects keyed by the 

8 xpum_stats_type_enum names. One device per call, no root. 

92. DRM ``fdinfo`` -- the kernel's per-client engine-busy counters. Covers 

10 consumer iGPUs with no root and no extra tool, but only on kernels new enough 

11 to publish i915 engine stats (5.19+, or 6.5+ under GuC submission). 

123. ``intel_gpu_top`` (Intel GPU Tools) -- reads the i915 PMU, so it covers 

13 essentially every consumer iGPU on kernel 4.16+, but needs CAP_PERFMON (or 

14 root); it falls through cleanly when the permission isn't granted. 

15 

16The fdinfo and intel_gpu_top sources report one device (the consumer case is a 

17single iGPU); their reading is keyed to the lowest requested index. VRAM is left 

18as the 0/0 structural sentinel since none of these report an iGPU's total memory. 

19""" 

20 

21from __future__ import annotations 

22 

23import functools 

24import json 

25import shutil 

26import subprocess 

27from dataclasses import dataclass 

28from enum import StrEnum 

29 

30from lilbee.providers.fleet.gpu_backends import fdinfo 

31from lilbee.providers.fleet.gpu_backends.base import UtilSample, extract_int, run_smi 

32 

33_TOOL_XPU_SMI = "xpu-smi" 

34_TOOL_IGT = "intel_gpu_top" 

35_TIMEOUT_S = 5.0 

36 

37# xpu-smi device_level metrics_type names (xpum_stats_type_enum). 

38_METRIC_UTIL = "XPUM_STATS_GPU_UTILIZATION" 

39_METRIC_TEMP = "XPUM_STATS_GPU_CORE_TEMPERATURE" 

40 

41# intel_gpu_top streams JSON samples forever; run it for one short window and 

42# read the partial output. A couple of 200ms periods lands a fresh per-interval 

43# reading while keeping the (synchronous) probe from stalling the caller long. 

44_IGT_SAMPLE_MS = 200 

45_IGT_CAPTURE_S = 0.6 

46 

47# The i915 DRM driver name, for the fdinfo reader. 

48_I915 = "i915" 

49 

50# intel_gpu_top needs CAP_PERFMON to read the i915 PMU. When it is installed but 

51# unprivileged, the util reads back empty. A working PMU streams, so the check 

52# hits the timeout and reports usable. 

53_IGT_PERM_CHECK_S = 1.0 

54 

55 

56class IntelHintKind(StrEnum): 

57 """Which fix would make an Intel GPU's utilization readable.""" 

58 

59 GRANT = "grant" # intel_gpu_top is installed but the i915 PMU is permission-blocked 

60 INSTALL = "install" # intel_gpu_top is not installed at all 

61 

62 

63@dataclass(frozen=True) 

64class IntelUtilHint: 

65 """An actionable fix for an unreadable Intel GPU utilization reading.""" 

66 

67 kind: IntelHintKind 

68 binary: str | None # the intel_gpu_top path when installed, else None 

69 

70 

71class IntelBackend: 

72 """Intel util from xpu-smi, then DRM fdinfo, then intel_gpu_top.""" 

73 

74 def sample(self, indices: frozenset[int]) -> dict[int, UtilSample]: 

75 for source in (_xpu_smi_samples, _fdinfo_samples, _intel_gpu_top_samples): 

76 result = source(indices) 

77 if result: 

78 return result 

79 return {} 

80 

81 

82def _xpu_smi_output(index: int) -> str: 

83 """xpu-smi stats JSON stdout for one device, or "" when it can't run.""" 

84 return run_smi(_TOOL_XPU_SMI, ["stats", "-d", str(index), "-j"], _TIMEOUT_S) 

85 

86 

87def _device_level(data: object) -> list[object]: 

88 """Return the device_level metric array from parsed stats JSON, or [].""" 

89 # stats -d <id> -j emits a single device object with a "device_level" array. 

90 # Tolerate a bare list or a {"device_list": [...]} wrapper defensively. 

91 if isinstance(data, dict): 

92 top_level = data.get("device_level") 

93 if isinstance(top_level, list): 

94 return top_level 

95 wrapped = data.get("device_list") 

96 if isinstance(wrapped, list) and wrapped and isinstance(wrapped[0], dict): 

97 inner = wrapped[0].get("device_level") 

98 return inner if isinstance(inner, list) else [] 

99 if isinstance(data, list) and data and isinstance(data[0], dict): 

100 inner = data[0].get("device_level") 

101 return inner if isinstance(inner, list) else [] 

102 return [] 

103 

104 

105def _metric_int(entries: list[object], metrics_type: str) -> int | None: 

106 """First device_level entry with this metrics_type, coerced to int, or None.""" 

107 for entry in entries: 

108 if isinstance(entry, dict) and entry.get("metrics_type") == metrics_type: 

109 return extract_int(entry, ("value",)) 

110 return None 

111 

112 

113def _parse_xpu_smi(raw: str, index: int) -> UtilSample | None: 

114 """Parse one device's xpu-smi stats JSON into a UtilSample, or None on failure.""" 

115 if not raw: 

116 return None 

117 try: 

118 data = json.loads(raw) 

119 except json.JSONDecodeError: 

120 return None 

121 entries = _device_level(data) 

122 if not entries: 

123 return None 

124 return UtilSample( 

125 index=index, 

126 utilization_pct=_metric_int(entries, _METRIC_UTIL), 

127 temperature_c=_metric_int(entries, _METRIC_TEMP), 

128 # stats reports memory-used but not total; leave the 0/0 sentinel so the 

129 # orchestrator keeps structural VRAM. 

130 free_bytes=0, 

131 total_bytes=0, 

132 ) 

133 

134 

135def _xpu_smi_samples(indices: frozenset[int]) -> dict[int, UtilSample]: 

136 samples: dict[int, UtilSample] = {} 

137 for index in sorted(indices): 

138 sample = _parse_xpu_smi(_xpu_smi_output(index), index) 

139 if sample is not None: 

140 samples[index] = sample 

141 return samples 

142 

143 

144def _fdinfo_samples(indices: frozenset[int]) -> dict[int, UtilSample]: 

145 if not indices: 

146 return {} 

147 util = fdinfo.read_drm_util(_I915) 

148 if util is None: 

149 return {} 

150 return _single(indices, util) 

151 

152 

153def _intel_gpu_top_output() -> str: 

154 """intel_gpu_top -J partial stdout over one short window, or "" on failure. 

155 

156 intel_gpu_top streams until killed, so it always hits the timeout on success; 

157 a permission failure exits fast with empty stdout. Both yield the right thing. 

158 """ 

159 binary = shutil.which(_TOOL_IGT) 

160 if binary is None: 

161 return "" 

162 try: 

163 proc = subprocess.run( # noqa: S603 - fixed args, resolved binary 

164 [binary, "-J", "-s", str(_IGT_SAMPLE_MS)], 

165 capture_output=True, 

166 text=True, 

167 encoding="utf-8", 

168 errors="replace", 

169 timeout=_IGT_CAPTURE_S, 

170 check=False, 

171 ) 

172 except subprocess.TimeoutExpired as exc: 

173 out = exc.stdout 

174 if out is None: 

175 return "" 

176 return out if isinstance(out, str) else out.decode(errors="replace") 

177 except (OSError, subprocess.SubprocessError): 

178 return "" 

179 return proc.stdout 

180 

181 

182def _last_json_object(raw: str) -> dict[str, object] | None: 

183 """Parse the last complete sample from intel_gpu_top's streamed JSON array.""" 

184 raw = raw.strip() 

185 if not raw: 

186 return None 

187 # The stream is an unclosed '[ {..}, {..},' -- close it and take the last item. 

188 if raw.startswith("[") and not raw.endswith("]"): 

189 raw = raw.rstrip().rstrip(",") + "]" 

190 try: 

191 data = json.loads(raw) 

192 except json.JSONDecodeError: 

193 return None 

194 if isinstance(data, list): 

195 last = data[-1] if data else None 

196 return last if isinstance(last, dict) else None 

197 return data if isinstance(data, dict) else None 

198 

199 

200def _igt_max_busy(raw: str) -> int | None: 

201 """Busiest engine's busy percent from an intel_gpu_top sample, or None.""" 

202 obj = _last_json_object(raw) 

203 if obj is None: 

204 return None 

205 engines = obj.get("engines") 

206 if not isinstance(engines, dict): 

207 return None 

208 busies = [ 

209 float(eng["busy"]) 

210 for eng in engines.values() 

211 if isinstance(eng, dict) and isinstance(eng.get("busy"), (int, float)) 

212 ] 

213 if not busies: 

214 return None 

215 return round(max(busies)) 

216 

217 

218def _intel_gpu_top_samples(indices: frozenset[int]) -> dict[int, UtilSample]: 

219 if not indices: 

220 return {} 

221 util = _igt_max_busy(_intel_gpu_top_output()) 

222 if util is None: 

223 return {} 

224 return _single(indices, util) 

225 

226 

227def _single(indices: frozenset[int], util: int) -> dict[int, UtilSample]: 

228 """One device-level reading keyed to the lowest requested index (iGPU case).""" 

229 idx = min(indices) 

230 return { 

231 idx: UtilSample(idx, utilization_pct=util, temperature_c=None, free_bytes=0, total_bytes=0) 

232 } 

233 

234 

235@functools.cache 

236def intel_util_hint() -> IntelUtilHint | None: 

237 """The fix that would make Intel GPU util readable, or None when nothing is blocked. 

238 

239 INSTALL when intel_gpu_top is absent (igt-gpu-tools reads the i915 PMU on 

240 kernels too old to publish fdinfo engine counters), GRANT when it is 

241 installed but permission-blocked, None when the tool already works. Cached: 

242 the install/permission state does not change within a run. 

243 """ 

244 binary = shutil.which(_TOOL_IGT) 

245 if binary is None: 

246 return IntelUtilHint(IntelHintKind.INSTALL, None) 

247 if _igt_permission_denied(binary): 

248 return IntelUtilHint(IntelHintKind.GRANT, binary) 

249 return None 

250 

251 

252def _igt_permission_denied(binary: str) -> bool: 

253 """True when intel_gpu_top reports the i915 PMU is permission-blocked.""" 

254 try: 

255 proc = subprocess.run( # noqa: S603 - resolved binary, fixed args 

256 [binary, "-J", "-s", str(_IGT_SAMPLE_MS)], 

257 capture_output=True, 

258 text=True, 

259 encoding="utf-8", 

260 errors="replace", 

261 timeout=_IGT_PERM_CHECK_S, 

262 check=False, 

263 ) 

264 except subprocess.TimeoutExpired: 

265 return False # it streamed rather than erroring out, so the PMU is usable 

266 except (OSError, subprocess.SubprocessError): 

267 return False 

268 return "CAP_PERFMON" in proc.stderr or "Permission denied" in proc.stderr