Skip to content
This repository was archived by the owner on Mar 11, 2026. It is now read-only.

Commit 1f79dc0

Browse files
authored
feat(training): add MLflow machine metrics collection (#5)
* refactor(scripts): add docstrings and simplify MLflow wrapper error handling - add docstrings to tensor/array helpers and extraction utilities - clarify function docstrings and argument descriptions - fix cli_args param name in skrl_training docstring - simplify tracking_data validation to a single AttributeError ♻️ - Generated by Copilot * feat(scripts): add system metrics collection and log to MLflow - add SystemMetricsCollector using psutil and pynvml for CPU/GPU/disk metrics - integrate system metrics into create_mlflow_logging_wrapper and log with mlflow - add psutil/pynvml to requirements and update cspell dictionary 📊 - Generated by Copilot * refactor(src): move metric extraction and system metrics collector to utils.metrics - add training/utils/metrics.py with value/tensor/array extraction helpers, _STANDARD_METRIC_ATTRS, and SystemMetricsCollector - update skrl_mlflow_agent to import helpers from training.utils.metrics and remove duplicated code - export SystemMetricsCollector from training.utils.__init__ ♻️ - Generated by Copilot * refactor(src): improve exception handling and logging in Azure bootstrap and scripts - capture and log exception details when closing the simulation app - chain exceptions in smoke_test main to preserve original trace - wrap MLClient, workspace access, and MLflow setup with AzureConfigError on failure 🛠️ - Generated by Copilot
1 parent 8cdadac commit 1f79dc0

9 files changed

Lines changed: 371 additions & 178 deletions

File tree

.cspell-dictionary.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
azureml
2+
DDPG
23
IPPO
34
isaaclab
45
isaacsim
56
MAPPO
67
nvcr
8+
nvml
79
PYTHONHASHSEED
810
PYTHONPATH
911
rollouts

requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ azure-identity>=1.13.0
7474
azure-ai-ml
7575
azureml-mlflow
7676
mlflow
77+
psutil>=5.9.0 # CPU, memory, disk metrics
78+
pynvml>=11.5.0 # NVIDIA GPU metrics (optional)
7779

7880
# Linux-only dependencies (x86_64)
7981
# These are installed conditionally based on platform

src/training/requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,6 @@ azure-ai-ml
33
azureml-mlflow
44
mlflow
55
packaging
6+
psutil>=5.9.0 # CPU, memory, disk metrics
7+
pynvml>=11.5.0 # NVIDIA GPU metrics (optional)
68
skrl>=1.4.3

src/training/scripts/skrl_mlflow_agent.py

Lines changed: 71 additions & 163 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@
3232
All entries in agent.tracking_data dict are extracted, supporting custom metrics
3333
from different SKRL algorithms (PPO, SAC, TD3, DDPG, A2C, etc.).
3434
35+
**System Metrics:**
36+
Collected via psutil and pynvml with system/ prefix:
37+
- CPU: system/cpu_utilization_percentage
38+
- Memory: system/memory_used_megabytes, system/memory_percent, system/memory_available_megabytes
39+
- GPU: system/gpu_{i}_utilization_percentage, system/gpu_{i}_memory_percent, system/gpu_{i}_memory_used_megabytes, system/gpu_{i}_power_watts
40+
- Disk: system/disk_used_gigabytes, system/disk_percent, system/disk_available_gigabytes
41+
3542
Metric Logging
3643
--------------
3744
Metrics are logged to MLflow after each agent._update() call, which is when SKRL
@@ -57,6 +64,7 @@
5764
agent=runner.agent,
5865
mlflow_module=mlflow,
5966
metric_filter=None,
67+
collect_gpu_metrics=True,
6068
)
6169
6270
# Monkey-patch the agent's _update method
@@ -72,6 +80,13 @@
7280
import logging
7381
from typing import Any, Callable, Protocol, runtime_checkable
7482

83+
from training.utils.metrics import (
84+
SystemMetricsCollector,
85+
_STANDARD_METRIC_ATTRS,
86+
_extract_from_tracking_data,
87+
_extract_from_value,
88+
)
89+
7590
_LOGGER = logging.getLogger(__name__)
7691

7792

@@ -87,161 +102,34 @@ class SkrlAgent(Protocol):
87102
class MLflowModule(Protocol):
88103
"""Protocol defining the MLflow API used for logging metrics."""
89104

90-
def log_metrics(self, metrics: dict[str, float], step: int | None = None) -> None: ...
91-
92-
93-
def _is_tensor_scalar(value: Any) -> bool:
94-
return hasattr(value, "item") and hasattr(value, "numel") and value.numel() == 1
95-
96-
97-
def _is_tensor_array(value: Any) -> bool:
98-
return hasattr(value, "item") and hasattr(value, "numel") and value.numel() > 1
99-
100-
101-
def _is_numpy_array(value: Any) -> bool:
102-
return hasattr(value, "mean") and hasattr(value, "__len__") and len(value) > 1
103-
104-
105-
def _is_single_element_sequence(value: Any) -> bool:
106-
return hasattr(value, "__len__") and len(value) == 1
107-
108-
109-
def _extract_tensor_scalar(name: str, value: Any, metrics: dict[str, float]) -> None:
110-
metrics[name] = float(value.item())
111-
112-
113-
def _extract_tensor_statistics(name: str, value: Any, metrics: dict[str, float]) -> None:
114-
if hasattr(value, "mean"):
115-
metrics[f"{name}/mean"] = float(value.mean().item())
116-
if hasattr(value, "std"):
117-
metrics[f"{name}/std"] = float(value.std().item())
118-
if hasattr(value, "min"):
119-
metrics[f"{name}/min"] = float(value.min().item())
120-
if hasattr(value, "max"):
121-
metrics[f"{name}/max"] = float(value.max().item())
122-
123-
124-
def _extract_numpy_statistics(name: str, value: Any, metrics: dict[str, float]) -> None:
125-
import numpy as np
126-
127-
arr = np.asarray(value)
128-
metrics[f"{name}/mean"] = float(np.mean(arr))
129-
metrics[f"{name}/std"] = float(np.std(arr))
130-
metrics[f"{name}/min"] = float(np.min(arr))
131-
metrics[f"{name}/max"] = float(np.max(arr))
132-
133-
134-
def _extract_from_value(name: str, value: int | float | Any, metrics: dict[str, float]) -> None:
135-
"""Extract numeric value and add to metrics dict.
136-
137-
Handles tensors, arrays, and numeric types. For tensors/arrays with multiple
138-
elements, extracts mean, std, min, max statistics.
139-
140-
Args:
141-
name: Metric name
142-
value: Value to extract (can be tensor, array, or numeric)
143-
metrics: Output dictionary to populate
144-
"""
145-
if value is None:
146-
return
147-
148-
try:
149-
if _is_tensor_scalar(value):
150-
_extract_tensor_scalar(name, value, metrics)
151-
elif _is_tensor_array(value):
152-
_extract_tensor_statistics(name, value, metrics)
153-
elif hasattr(value, "item"):
154-
metrics[name] = float(value.item())
155-
elif _is_numpy_array(value):
156-
_extract_numpy_statistics(name, value, metrics)
157-
elif _is_single_element_sequence(value):
158-
metrics[name] = float(value[0])
159-
else:
160-
metrics[name] = float(value)
161-
except (ValueError, TypeError, AttributeError, IndexError) as exc:
162-
_LOGGER.debug("Could not convert %s to float: %s", name, exc)
163-
164-
165-
def _extract_from_tracking_data(
166-
data: dict[str, Any],
167-
metrics: dict[str, float],
168-
prefix: str,
169-
max_depth: int = 2,
170-
) -> None:
171-
"""Recursively extract metrics from tracking_data dict.
172-
173-
Args:
174-
data: Dictionary to extract from (tracking_data or nested dict)
175-
metrics: Output dictionary to populate with metrics
176-
prefix: Metric name prefix for nested structures
177-
max_depth: Maximum recursion depth to prevent infinite loops
178-
"""
179-
if max_depth <= 0:
180-
return
181-
182-
for key, value in data.items():
183-
metric_name = f"{prefix}{key}" if prefix else key
184-
185-
if isinstance(value, dict):
186-
_extract_from_tracking_data(value, metrics, f"{metric_name}/", max_depth - 1)
187-
else:
188-
_extract_from_value(metric_name, value, metrics)
189-
190-
191-
_STANDARD_METRIC_ATTRS = [
192-
"episode_reward",
193-
"episode_reward_mean",
194-
"episode_length",
195-
"episode_length_mean",
196-
"cumulative_rewards",
197-
"mean_rewards",
198-
"episode_lengths",
199-
"success_rate",
200-
"policy_loss",
201-
"value_loss",
202-
"critic_loss",
203-
"entropy",
204-
"learning_rate",
205-
"lr",
206-
"grad_norm",
207-
"gradient_norm",
208-
"kl_divergence",
209-
"kl",
210-
"timesteps",
211-
"timesteps_total",
212-
"total_timesteps",
213-
"iterations",
214-
"iterations_total",
215-
"fps",
216-
"time_elapsed",
217-
"epoch_time",
218-
"rollout_time",
219-
"learning_time",
220-
]
105+
def log_metrics(
106+
self,
107+
metrics: dict[str, float],
108+
step: int | None = None,
109+
synchronous: bool = True,
110+
) -> None: ...
221111

222112

223113
def _has_tracking_data(agent: SkrlAgent) -> bool:
114+
"""Check if agent has valid tracking_data dict."""
224115
return hasattr(agent, "tracking_data") and isinstance(agent.tracking_data, dict)
225116

226117

227118
def _extract_metrics_from_agent(
228119
agent: SkrlAgent,
229120
metric_filter: set[str] | None = None,
230121
) -> dict[str, float]:
231-
"""Extract metrics from the SKRL agent's internal state.
122+
"""Extract metrics from SKRL agent's internal state.
232123
233-
Extracts metrics from multiple sources:
234-
- agent.tracking_data dict (SKRL's primary tracking mechanism)
235-
- Direct agent attributes for common metrics
236-
- Nested structures in tracking_data
237-
- Statistical aggregations (mean, std, min, max) when available
124+
Extracts from agent.tracking_data dict, direct attributes, and nested
125+
structures. Multi-element values produce mean/std/min/max statistics.
238126
239127
Args:
240-
agent: SKRL agent instance with tracking_data dict
241-
metric_filter: Optional set of metric names to include
128+
agent: SKRL agent instance with tracking_data dict.
129+
metric_filter: Optional set of metric names to include.
242130
243131
Returns:
244-
Dictionary of metric names to float values, filtered by metric_filter if set
132+
Dictionary of metric names to float values.
245133
"""
246134
metrics: dict[str, float] = {}
247135

@@ -262,48 +150,68 @@ def create_mlflow_logging_wrapper(
262150
agent: SkrlAgent,
263151
mlflow_module: MLflowModule,
264152
metric_filter: set[str] | None = None,
153+
collect_gpu_metrics: bool = True,
265154
) -> Callable[[int, int], Any]:
266155
"""Create closure that wraps agent._update with MLflow logging.
267156
268-
Returns a function suitable for monkey-patching agent._update. The returned
269-
closure will call the original agent._update method and then extract and log
270-
metrics to MLflow.
157+
Returns a function that calls the original agent._update method then
158+
extracts and logs metrics to MLflow.
271159
272160
Args:
273-
agent: SKRL agent instance to extract metrics from
274-
mlflow_module: MLflow module for logging metrics
275-
metric_filter: Optional set of metric names to include
161+
agent: SKRL agent instance to extract metrics from.
162+
mlflow_module: MLflow module for logging metrics.
163+
metric_filter: Optional set of metric names to include.
164+
collect_gpu_metrics: Enable GPU metrics collection (default: True).
276165
277166
Returns:
278-
Closure function with signature (timestep: int, timesteps: int) -> Any
279-
that can be assigned to agent._update for monkey-patching
167+
Closure function suitable for monkey-patching agent._update.
168+
169+
Raises:
170+
AttributeError: If agent lacks tracking_data attribute.
280171
281172
Example:
282-
>>> wrapper_func = create_mlflow_logging_wrapper(runner.agent, mlflow, None)
283-
>>> runner.agent._update = wrapper_func
173+
>>> wrapper = create_mlflow_logging_wrapper(runner.agent, mlflow)
174+
>>> runner.agent._update = wrapper
284175
"""
285176
if not _has_tracking_data(agent):
286-
if not hasattr(agent, "tracking_data"):
287-
raise AttributeError(
288-
"Agent must have 'tracking_data' attribute for MLflow metric logging. "
289-
f"Agent type {type(agent).__name__} does not support metric tracking."
290-
)
291-
raise TypeError(
292-
f"Agent 'tracking_data' must be a dict, got {type(agent.tracking_data).__name__}. "
293-
"Cannot extract metrics from non-dict tracking_data."
177+
raise AttributeError(
178+
"Agent must have 'tracking_data' attribute for MLflow metric logging. "
179+
f"Agent type {type(agent).__name__} does not support metric tracking."
294180
)
295181

182+
system_metrics_collector = SystemMetricsCollector(
183+
collect_gpu=collect_gpu_metrics,
184+
collect_disk=True,
185+
)
186+
_LOGGER.debug(
187+
"System metrics collector initialized (GPU: %s)",
188+
collect_gpu_metrics,
189+
)
190+
296191
original_update = agent._update
297192

298193
def mlflow_logging_update(timestep: int, timesteps: int) -> Any:
299-
"""Wrapped _update that logs metrics to MLflow after each training update."""
194+
"""Call original _update and log metrics to MLflow."""
300195
result = original_update(timestep, timesteps)
301196

302197
try:
303-
metrics = _extract_metrics_from_agent(agent, metric_filter)
304-
if metrics and mlflow_module:
305-
mlflow_module.log_metrics(metrics, step=timestep)
306-
elif not metrics:
198+
training_metrics = _extract_metrics_from_agent(agent, metric_filter)
199+
200+
system_metrics = {}
201+
try:
202+
system_metrics = system_metrics_collector.collect_metrics()
203+
_LOGGER.debug(
204+
"System metrics collected: %d metrics",
205+
len(system_metrics),
206+
)
207+
except Exception as exc:
208+
_LOGGER.debug("System metrics collection failed: %s", exc)
209+
210+
all_metrics = {**training_metrics, **system_metrics}
211+
212+
if all_metrics and mlflow_module:
213+
mlflow_module.log_metrics(all_metrics, step=timestep, synchronous=False)
214+
elif not all_metrics:
307215
_LOGGER.debug("No metrics extracted at timestep %d", timestep)
308216
except Exception as exc:
309217
_LOGGER.warning("Failed to log metrics at timestep %d: %s", timestep, exc)

src/training/scripts/skrl_training.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ def _prepare_log_paths(agent_cfg: dict[str, Any], cli_args: argparse.Namespace)
123123
124124
Args:
125125
agent_cfg: Agent configuration dictionary to populate with experiment details.
126-
args_cli: Parsed CLI arguments that drive naming and algorithm metadata.
126+
cli_args: Parsed CLI arguments that drive naming and algorithm metadata.
127127
128128
Returns:
129129
Absolute path to the run-specific log directory.
@@ -705,8 +705,8 @@ def _close_simulation(simulation_app: Any | None) -> None:
705705
return
706706
try:
707707
simulation_app.close()
708-
except Exception:
709-
_LOGGER.info("Simulation app close raised exception (expected during shutdown)")
708+
except Exception as exc:
709+
_LOGGER.info("Simulation app close raised exception (expected during shutdown): %s", exc)
710710

711711

712712
def _build_run_descriptor(

src/training/scripts/smoke_test_azure.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,6 @@ def main(argv: Sequence[str] | None = None) -> None:
197197
if __name__ == "__main__":
198198
try:
199199
main(sys.argv[1:])
200-
except Exception: # pragma: no cover - ensures non-zero exit when unexpected errors occur
200+
except Exception as exc:
201201
_LOGGER.exception("Azure connectivity smoke test failed")
202-
raise SystemExit(1)
202+
raise SystemExit(1) from exc

src/training/utils/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22

33
from training.utils.context import AzureConfigError, AzureMLContext, bootstrap_azure_ml
44
from training.utils.env import require_env, set_env_defaults
5+
from training.utils.metrics import SystemMetricsCollector
56

67
__all__ = [
78
"AzureConfigError",
89
"AzureMLContext",
10+
"SystemMetricsCollector",
911
"bootstrap_azure_ml",
1012
"require_env",
1113
"set_env_defaults",

0 commit comments

Comments
 (0)