Coverage for src/lilbee/providers/fleet/gpu_backends/fdinfo.py: 100%
75 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
1"""Generic DRM fdinfo GPU-utilization reader (no root, no vendor tool).
3Modern kernels publish per-client GPU engine-busy counters under
4``/proc/<pid>/fdinfo/<fd>`` for any DRM driver (i915, xe, amdgpu, ...). Each open
5DRM file reports monotonic nanosecond counters per engine::
7 drm-driver: i915
8 drm-pdev: 0000:00:02.0
9 drm-engine-render: 9288864723 ns
10 drm-engine-video: 0 ns
12Utilization is the busiest engine's share of wall-clock time over a short window:
13we snapshot the counters, wait, snapshot again, and divide the delta by the
14elapsed time. Counters are summed across every DRM client of the matching driver,
15so a workload split across processes still reads correctly.
17This needs no elevated privileges (a process can read its own and same-user
18clients' fdinfo) and no extra tool, but the engine counters only exist on kernels
19new enough to publish them (i915 landed them in 5.19 for execlists submission,
206.5 where GuC submission is active); older kernels omit the
21``drm-engine-*`` lines and this reader reports nothing, letting a caller fall back.
22"""
24from __future__ import annotations
26import time
27from pathlib import Path
29_PROC = Path("/proc")
30_ENGINE_PREFIX = "drm-engine-"
31_DRIVER_KEY = "drm-driver"
32_DEFAULT_INTERVAL_S = 0.2
35def read_drm_util(
36 driver: str,
37 interval_s: float = _DEFAULT_INTERVAL_S,
38 proc: Path = _PROC,
39) -> int | None:
40 """Busiest-engine utilization percent for *driver*'s GPU, or None.
42 Returns None when the kernel publishes no engine counters for this driver
43 (too old, or no active DRM clients), so the caller can fall back to a tool.
44 """
45 first = _snapshot(driver, proc)
46 if first is None:
47 return None
48 time.sleep(interval_s)
49 second = _snapshot(driver, proc)
50 if second is None:
51 return None
52 busy1, wall1 = first
53 busy2, wall2 = second
54 elapsed = wall2 - wall1
55 if elapsed <= 0:
56 return None
57 peak = 0.0
58 for engine, ns2 in busy2.items():
59 delta = ns2 - busy1.get(engine, 0)
60 if delta > 0:
61 peak = max(peak, delta / elapsed)
62 return round(min(peak, 1.0) * 100)
65def _snapshot(driver: str, proc: Path) -> tuple[dict[str, int], int] | None:
66 """Sum engine-busy ns per engine across all DRM clients of *driver*.
68 Returns (totals_by_engine, monotonic_ns), or None when no client publishes
69 engine counters for this driver.
70 """
71 totals: dict[str, int] = {}
72 found = False
73 try:
74 pids = [entry for entry in proc.iterdir() if entry.name.isdigit()]
75 except OSError:
76 return None
77 for pid in pids:
78 for engine, ns in _client_engine_ns(pid, driver):
79 totals[engine] = totals.get(engine, 0) + ns
80 found = True
81 if not found:
82 return None
83 return totals, time.monotonic_ns()
86def _client_engine_ns(pid: Path, driver: str) -> list[tuple[str, int]]:
87 """(engine, ns) pairs from every *driver* DRM client fd under this pid."""
88 pairs: list[tuple[str, int]] = []
89 try:
90 entries = list((pid / "fdinfo").iterdir())
91 except OSError:
92 return pairs
93 for entry in entries:
94 try:
95 text = entry.read_text(encoding="utf-8")
96 except OSError:
97 continue
98 if not _driver_matches(text, driver):
99 continue
100 for line in text.splitlines():
101 if line.startswith(_ENGINE_PREFIX):
102 engine, ns = _parse_engine_line(line)
103 if ns is not None:
104 pairs.append((engine, ns))
105 return pairs
108def _driver_matches(text: str, driver: str) -> bool:
109 """True when the fdinfo names *driver* on its drm-driver line."""
110 for line in text.splitlines():
111 if line.startswith(_DRIVER_KEY):
112 _, _, val = line.partition(":")
113 return val.strip() == driver
114 return False
117def _parse_engine_line(line: str) -> tuple[str, int | None]:
118 """Parse 'drm-engine-render:\\t123 ns' into ('render', 123)."""
119 key, _, val = line.partition(":")
120 engine = key[len(_ENGINE_PREFIX) :]
121 fields = val.split()
122 if not fields:
123 return engine, None
124 try:
125 return engine, int(fields[0])
126 except ValueError:
127 return engine, None