Skip to content

Commit 90fbf3c

Browse files
authored
Merge pull request #3172 from bghira/feature/3167-system-telemetry
Add manual system telemetry metrics logging
2 parents 88abc5d + c80af8a commit 90fbf3c

9 files changed

Lines changed: 483 additions & 0 deletions

File tree

documentation/webui/LOCAL_METRICS.es.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ El directorio de salida contiene:
1919

2020
Una reanudación añade registros. Archiva el informe HTML junto con el directorio de salida porque usa rutas relativas para los medios.
2121

22+
Cuando un tracker no recopila telemetría del sistema de forma nativa, SimpleTuner registra métricas numéricas de CPU, memoria, disco, red y GPU para ese tracker. WandB se omite porque su cliente ya informa métricas del host.
23+
2224
## WebUI y API
2325

2426
Abre **Metrics** y **Training Runs** para elegir escalares, comparar validaciones por prompt/paso y abrir el informe. **System** conserva salud de GPU y Prometheus.

documentation/webui/LOCAL_METRICS.hi.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ SimpleTuner बाहरी सेवा के बिना प्रशिक
1717

1818
Resume पर records जोड़े जाते हैं। HTML relative media paths उपयोग करता है, इसलिए इसे output directory के साथ archive करें।
1919

20+
जब कोई tracker system telemetry native रूप से collect नहीं करता, SimpleTuner उस tracker के लिए CPU, memory, disk, network और GPU की numeric metrics लिखता है। WandB को छोड़ा जाता है क्योंकि उसका client host metrics पहले से report करता है।
21+
2022
## WebUI और API
2123

2224
**Metrics** में **Training Runs** खोलें। यहाँ scalar चुन सकते हैं, prompt/step के अनुसार validation तुलना कर सकते हैं और offline report खोल सकते हैं। **System** में GPU health और Prometheus configuration रहती है।

documentation/webui/LOCAL_METRICS.ja.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717

1818
再開時は既存履歴へ追記します。HTML は相対メディアパスを使うため、出力ディレクトリと一緒に保存してください。
1919

20+
tracker が system telemetry を標準で収集しない場合、SimpleTuner は CPU、メモリ、ディスク、ネットワーク、GPU の数値メトリクスをその tracker に記録します。WandB はクライアント側で host metrics を収集するため、手動記録の対象外です。
21+
2022
## WebUI と API
2123

2224
**Metrics****Training Runs** でスカラーを選択し、prompt と step ごとに検証結果を比較できます。**System** には GPU health と Prometheus 設定があります。

documentation/webui/LOCAL_METRICS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ The JSONL files are the raw interface for analysis tools. A resumed run appends
2525

2626
The HTML report embeds a bounded copy of the scalar history and uses relative paths for validation media. Archive it with the output directory.
2727

28+
When a tracker does not collect system telemetry natively, SimpleTuner records numeric CPU, memory, disk, network, and GPU telemetry for that tracker. WandB is skipped for manual system telemetry because its client already reports host metrics.
29+
2830
## WebUI
2931

3032
Open **Metrics**, then **Training Runs**. Runs are discovered from saved WebUI environments. The page provides:

documentation/webui/LOCAL_METRICS.pt-BR.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ Use valores separados por virgula, como `report_to=simpletuner,wandb`, para ativ
1717

1818
Ao retomar, novos registros são anexados. Arquive o HTML com o diretório de saída, pois os caminhos de mídia são relativos.
1919

20+
Quando um tracker não coleta telemetria do sistema nativamente, o SimpleTuner registra métricas numéricas de CPU, memória, disco, rede e GPU para esse tracker. O WandB é ignorado porque seu cliente já relata métricas do host.
21+
2022
## WebUI e API
2123

2224
Abra **Metrics** e **Training Runs** para selecionar escalares, comparar validações por prompt/etapa e abrir o relatório. **System** mantém a saúde das GPUs e a configuração do Prometheus.

documentation/webui/LOCAL_METRICS.zh.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ SimpleTuner 可以在不使用外部服务的情况下记录训练指标:
1717

1818
恢复训练时会追加记录。HTML 使用相对媒体路径,因此应与输出目录一起归档。
1919

20+
当 tracker 本身不收集系统遥测时,SimpleTuner 会为该 tracker 记录 CPU、内存、磁盘、网络和 GPU 的数值指标。WandB 会被跳过,因为它的客户端已经报告主机指标。
21+
2022
## WebUI 与 API
2123

2224
打开 **Metrics****Training Runs**,可选择标量、按 prompt/step 比较验证结果并打开离线报告。**System** 保留 GPU 健康和 Prometheus 设置。
Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import logging
5+
import os
6+
import platform
7+
import plistlib
8+
import re
9+
import shutil
10+
import subprocess
11+
import time
12+
from pathlib import Path
13+
from typing import Any, Iterable, Optional
14+
15+
import psutil
16+
17+
from simpletuner.helpers.training.reporting import report_to_tokens
18+
19+
logger = logging.getLogger(__name__)
20+
21+
NATIVE_SYSTEM_METRIC_TRACKERS = {"wandb"}
22+
23+
24+
def should_collect_manual_system_metrics(report_to: Any) -> bool:
25+
return any(token not in NATIVE_SYSTEM_METRIC_TRACKERS and token != "none" for token in report_to_tokens(report_to))
26+
27+
28+
def _coerce_number(value: Any) -> Optional[float]:
29+
if isinstance(value, bool) or value is None:
30+
return None
31+
if isinstance(value, (int, float)):
32+
return float(value)
33+
if isinstance(value, str):
34+
match = re.search(r"-?\d+(?:\.\d+)?", value)
35+
if not match:
36+
return None
37+
try:
38+
return float(match.group(0))
39+
except ValueError:
40+
return None
41+
return None
42+
43+
44+
def _gib(value: Optional[float]) -> Optional[float]:
45+
if value is None:
46+
return None
47+
return round(float(value) / (1024**3), 3)
48+
49+
50+
def _put_metric(metrics: dict[str, float], name: str, value: Any, *, digits: int = 3) -> None:
51+
number = _coerce_number(value)
52+
if number is None:
53+
return
54+
metrics[name] = round(number, digits)
55+
56+
57+
def _first_key_value(entry: dict[str, Any], key_fragments: Iterable[tuple[str, ...]]) -> Optional[float]:
58+
lowered = [(key.lower(), value) for key, value in entry.items()]
59+
for fragments in key_fragments:
60+
for key, value in lowered:
61+
if all(fragment in key for fragment in fragments):
62+
return _coerce_number(value)
63+
return None
64+
65+
66+
class SystemMetricsSampler:
67+
def __init__(
68+
self,
69+
*,
70+
output_dir: str | os.PathLike[str],
71+
min_interval_seconds: float = 5.0,
72+
time_source: Any = None,
73+
) -> None:
74+
self.output_dir = Path(output_dir).expanduser()
75+
self.min_interval_seconds = float(min_interval_seconds)
76+
self._time_source = time_source or time.monotonic
77+
self._last_sample_time: Optional[float] = None
78+
self._last_net_counters: Optional[Any] = None
79+
self._last_net_time: Optional[float] = None
80+
81+
def sample(self, *, force: bool = False) -> dict[str, float]:
82+
now = float(self._time_source())
83+
if not force and self._last_sample_time is not None and now - self._last_sample_time < self.min_interval_seconds:
84+
return {}
85+
self._last_sample_time = now
86+
87+
metrics: dict[str, float] = {}
88+
self._sample_system(metrics, now)
89+
self._sample_gpu(metrics)
90+
return metrics
91+
92+
def _sample_system(self, metrics: dict[str, float], now: float) -> None:
93+
cpu_percent = psutil.cpu_percent(interval=None)
94+
_put_metric(metrics, "system/cpu_percent", cpu_percent, digits=1)
95+
96+
memory = psutil.virtual_memory()
97+
_put_metric(metrics, "system/memory_percent", memory.percent, digits=1)
98+
_put_metric(metrics, "system/memory_available_gb", _gib(float(memory.available)), digits=3)
99+
100+
disk = shutil.disk_usage(self.output_dir)
101+
_put_metric(metrics, "system/disk_free_gb", _gib(float(disk.free)), digits=3)
102+
if disk.total > 0:
103+
_put_metric(metrics, "system/disk_percent", (disk.used / disk.total) * 100.0, digits=1)
104+
105+
counters = psutil.net_io_counters()
106+
if self._last_net_counters is not None and self._last_net_time is not None:
107+
elapsed = now - self._last_net_time
108+
if elapsed > 0:
109+
sent_delta = max(0, counters.bytes_sent - self._last_net_counters.bytes_sent)
110+
recv_delta = max(0, counters.bytes_recv - self._last_net_counters.bytes_recv)
111+
_put_metric(metrics, "system/network_sent_mbps", (sent_delta * 8) / elapsed / 1_000_000, digits=3)
112+
_put_metric(metrics, "system/network_recv_mbps", (recv_delta * 8) / elapsed / 1_000_000, digits=3)
113+
self._last_net_counters = counters
114+
self._last_net_time = now
115+
116+
def _sample_gpu(self, metrics: dict[str, float]) -> None:
117+
try:
118+
import torch
119+
except ImportError:
120+
return
121+
122+
if torch.cuda.is_available():
123+
if bool(getattr(torch.version, "hip", None)):
124+
self._sample_rocm(metrics)
125+
else:
126+
self._sample_cuda(metrics)
127+
return
128+
129+
mps_backend = getattr(torch.backends, "mps", None)
130+
if mps_backend is not None and mps_backend.is_available():
131+
self._sample_mps(metrics, torch)
132+
133+
def _sample_cuda(self, metrics: dict[str, float]) -> None:
134+
if self._sample_nvml(metrics):
135+
return
136+
self._sample_nvidia_smi(metrics)
137+
138+
def _sample_nvml(self, metrics: dict[str, float]) -> bool:
139+
try:
140+
import pynvml
141+
except ImportError:
142+
return False
143+
144+
initialized_here = False
145+
try:
146+
pynvml.nvmlInit()
147+
initialized_here = True
148+
except Exception as exc:
149+
already_initialized = getattr(pynvml, "NVMLError_AlreadyInitialized", None)
150+
if already_initialized is None or not isinstance(exc, already_initialized):
151+
logger.debug("Unable to initialise NVML for system metrics: %s", exc, exc_info=True)
152+
return False
153+
154+
initial_metric_count = len(metrics)
155+
try:
156+
device_count = pynvml.nvmlDeviceGetCount()
157+
for index in range(device_count):
158+
handle = pynvml.nvmlDeviceGetHandleByIndex(index)
159+
prefix = f"system/gpu/{index}"
160+
try:
161+
util = pynvml.nvmlDeviceGetUtilizationRates(handle)
162+
_put_metric(metrics, f"{prefix}/utilization_percent", getattr(util, "gpu", None), digits=1)
163+
_put_metric(metrics, f"{prefix}/memory_utilization_percent", getattr(util, "memory", None), digits=1)
164+
except Exception:
165+
logger.debug("Unable to read NVML utilisation for GPU %s", index, exc_info=True)
166+
try:
167+
mem = pynvml.nvmlDeviceGetMemoryInfo(handle)
168+
_put_metric(metrics, f"{prefix}/memory_used_gb", _gib(float(mem.used)), digits=3)
169+
_put_metric(metrics, f"{prefix}/memory_total_gb", _gib(float(mem.total)), digits=3)
170+
if mem.total:
171+
_put_metric(metrics, f"{prefix}/memory_percent", (mem.used / mem.total) * 100.0, digits=1)
172+
except Exception:
173+
logger.debug("Unable to read NVML memory for GPU %s", index, exc_info=True)
174+
try:
175+
_put_metric(
176+
metrics,
177+
f"{prefix}/temperature_celsius",
178+
pynvml.nvmlDeviceGetTemperature(handle, pynvml.NVML_TEMPERATURE_GPU),
179+
digits=1,
180+
)
181+
except Exception:
182+
logger.debug("Unable to read NVML temperature for GPU %s", index, exc_info=True)
183+
try:
184+
_put_metric(metrics, f"{prefix}/fan_speed_percent", pynvml.nvmlDeviceGetFanSpeed(handle), digits=1)
185+
except Exception:
186+
logger.debug("Unable to read NVML fan speed for GPU %s", index, exc_info=True)
187+
try:
188+
_put_metric(metrics, f"{prefix}/power_usage_watts", pynvml.nvmlDeviceGetPowerUsage(handle) / 1000.0)
189+
except Exception:
190+
logger.debug("Unable to read NVML power usage for GPU %s", index, exc_info=True)
191+
return len(metrics) > initial_metric_count
192+
finally:
193+
if initialized_here:
194+
try:
195+
pynvml.nvmlShutdown()
196+
except Exception:
197+
logger.debug("Unable to shutdown NVML after system metrics sampling", exc_info=True)
198+
199+
def _sample_nvidia_smi(self, metrics: dict[str, float]) -> None:
200+
try:
201+
completed = subprocess.run(
202+
[
203+
"nvidia-smi",
204+
"--query-gpu=utilization.gpu,memory.used,memory.total,temperature.gpu,fan.speed,power.draw",
205+
"--format=csv,noheader,nounits",
206+
],
207+
check=True,
208+
capture_output=True,
209+
text=True,
210+
timeout=2,
211+
)
212+
except (FileNotFoundError, subprocess.SubprocessError) as exc:
213+
logger.debug("Unable to query nvidia-smi for system metrics: %s", exc, exc_info=True)
214+
return
215+
216+
for index, line in enumerate(completed.stdout.strip().splitlines()):
217+
values = [value.strip() for value in line.split(",")]
218+
if len(values) < 6:
219+
continue
220+
util, mem_used_mib, mem_total_mib, temperature, fan_speed, power = values[:6]
221+
prefix = f"system/gpu/{index}"
222+
_put_metric(metrics, f"{prefix}/utilization_percent", util, digits=1)
223+
mem_used = _coerce_number(mem_used_mib)
224+
mem_total = _coerce_number(mem_total_mib)
225+
if mem_used is not None:
226+
_put_metric(metrics, f"{prefix}/memory_used_gb", mem_used / 1024.0, digits=3)
227+
if mem_total is not None:
228+
_put_metric(metrics, f"{prefix}/memory_total_gb", mem_total / 1024.0, digits=3)
229+
if mem_used is not None and mem_total:
230+
_put_metric(metrics, f"{prefix}/memory_percent", (mem_used / mem_total) * 100.0, digits=1)
231+
_put_metric(metrics, f"{prefix}/temperature_celsius", temperature, digits=1)
232+
_put_metric(metrics, f"{prefix}/fan_speed_percent", fan_speed, digits=1)
233+
_put_metric(metrics, f"{prefix}/power_usage_watts", power)
234+
235+
def _sample_rocm(self, metrics: dict[str, float]) -> None:
236+
rocm_smi = shutil.which("rocm-smi")
237+
if not rocm_smi:
238+
return
239+
try:
240+
completed = subprocess.run(
241+
[rocm_smi, "--showuse", "--showmemuse", "--showtemp", "--showfan", "--showpower", "--json"],
242+
check=True,
243+
capture_output=True,
244+
text=True,
245+
timeout=2,
246+
)
247+
except (FileNotFoundError, subprocess.SubprocessError) as exc:
248+
logger.debug("Unable to query rocm-smi for system metrics: %s", exc, exc_info=True)
249+
return
250+
251+
try:
252+
payload = json.loads(completed.stdout or "{}")
253+
except json.JSONDecodeError as exc:
254+
logger.debug("Unable to parse rocm-smi JSON for system metrics: %s", exc, exc_info=True)
255+
return
256+
if not isinstance(payload, dict):
257+
return
258+
259+
for position, entry in enumerate(payload.values()):
260+
if not isinstance(entry, dict):
261+
continue
262+
prefix = f"system/gpu/{position}"
263+
_put_metric(metrics, f"{prefix}/utilization_percent", _first_key_value(entry, [("gpu", "use")]), digits=1)
264+
memory_percent = _first_key_value(entry, [("vram", "%"), ("memory", "%"), ("mem", "%")])
265+
_put_metric(metrics, f"{prefix}/memory_percent", memory_percent, digits=1)
266+
_put_metric(
267+
metrics, f"{prefix}/temperature_celsius", _first_key_value(entry, [("temperature",), ("temp",)]), digits=1
268+
)
269+
_put_metric(
270+
metrics, f"{prefix}/fan_speed_percent", _first_key_value(entry, [("fan", "%"), ("fan", "speed")]), digits=1
271+
)
272+
_put_metric(metrics, f"{prefix}/power_usage_watts", _first_key_value(entry, [("power",), ("watt",)]))
273+
274+
def _sample_mps(self, metrics: dict[str, float], torch: Any) -> None:
275+
prefix = "system/gpu/0"
276+
utilization = self._mps_utilization()
277+
_put_metric(metrics, f"{prefix}/utilization_percent", utilization, digits=1)
278+
279+
driver_alloc = getattr(torch.mps, "driver_allocated_memory", None)
280+
driver_total = getattr(torch.mps, "driver_total_memory", None)
281+
if callable(driver_alloc) and callable(driver_total):
282+
try:
283+
allocated = float(driver_alloc())
284+
total = float(driver_total())
285+
except Exception:
286+
logger.debug("Unable to query MPS memory statistics for system metrics", exc_info=True)
287+
else:
288+
_put_metric(metrics, f"{prefix}/memory_used_gb", _gib(allocated), digits=3)
289+
_put_metric(metrics, f"{prefix}/memory_total_gb", _gib(total), digits=3)
290+
if total > 0:
291+
_put_metric(metrics, f"{prefix}/memory_percent", (allocated / total) * 100.0, digits=1)
292+
293+
def _mps_utilization(self) -> Optional[float]:
294+
if platform.system() != "Darwin":
295+
return None
296+
try:
297+
completed = subprocess.run(
298+
["ioreg", "-r", "-k", "PerformanceStatistics", "-d", "1", "-a"],
299+
check=True,
300+
capture_output=True,
301+
text=False,
302+
timeout=2,
303+
)
304+
data = plistlib.loads(completed.stdout)
305+
except (FileNotFoundError, subprocess.SubprocessError, plistlib.InvalidFileException, ValueError) as exc:
306+
logger.debug("Unable to query MPS utilisation for system metrics: %s", exc, exc_info=True)
307+
return None
308+
if not isinstance(data, list):
309+
return None
310+
for entry in data:
311+
if not isinstance(entry, dict):
312+
continue
313+
perf = entry.get("PerformanceStatistics")
314+
if not isinstance(perf, dict):
315+
continue
316+
value = _coerce_number(perf.get("Device Utilization %"))
317+
if value is not None:
318+
return value
319+
return None
320+
321+
322+
def log_system_metrics_to_trackers(trackers: Iterable[Any], metrics: dict[str, float], *, step: int) -> None:
323+
if not metrics:
324+
return
325+
for tracker in trackers:
326+
name = str(getattr(tracker, "name", "") or "").strip().lower()
327+
if not name or name in NATIVE_SYSTEM_METRIC_TRACKERS:
328+
continue
329+
log = getattr(tracker, "log", None)
330+
if not callable(log):
331+
logger.warning("Tracker '%s' cannot receive manual system metrics because it has no log method.", name)
332+
continue
333+
try:
334+
log(metrics, step=step)
335+
except Exception as exc:
336+
logger.warning("Failed to log manual system metrics to tracker '%s': %s", name, exc)

0 commit comments

Comments
 (0)