diff --git a/nodescraper/plugins/inband/amdsmi/amdsmi_collector.py b/nodescraper/plugins/inband/amdsmi/amdsmi_collector.py index bf66c93c..d04b3fd7 100644 --- a/nodescraper/plugins/inband/amdsmi/amdsmi_collector.py +++ b/nodescraper/plugins/inband/amdsmi/amdsmi_collector.py @@ -42,10 +42,13 @@ AmdSmiStatic, AmdSmiVersion, BadPages, + CoreMetric, + CpuMetric, EccState, Fabric, Fw, FwListItem, + NodeInfo, Partition, PartitionCompute, PartitionMemory, @@ -110,6 +113,9 @@ class AmdSmiCollector(InBandDataCollector[AmdSmiDataModel, AmdSmiCollectorArgs]) CMD_RAS = "ras --cper --folder={folder}" CMD_RAS_AFID = "ras --afid --cper-file {cper_file}" CMD_FABRIC = "fabric" + CMD_NODE = "node" + CMD_METRIC_CPU = "metric --cpu all" + CMD_METRIC_CORE = "metric --core all" def _check_amdsmi_installed(self) -> bool: """Check if amd-smi is installed @@ -499,6 +505,39 @@ def get_fabric(self) -> List[Fabric]: built = self._build_amdsmi_sub_data(Fabric, fabric_entries) return built if isinstance(built, list) else ([built] if built else []) + def get_node(self) -> List[NodeInfo]: + """Get node-level data from amd-smi node --json.""" + ret = self._run_amd_smi_dict(self.CMD_NODE) + if ret is None: + return [] + if isinstance(ret, dict) and "node_data" in ret: + ret = ret["node_data"] + data = ret if isinstance(ret, list) else [ret] + built = self._build_amdsmi_sub_data(NodeInfo, data) + return built if isinstance(built, list) else ([built] if built else []) + + def get_cpu_metric(self) -> List[CpuMetric]: + """Get per-CPU-socket metrics from amd-smi metric --cpu all --json.""" + ret = self._run_amd_smi_dict(self.CMD_METRIC_CPU) + if ret is None: + return [] + if isinstance(ret, dict) and "cpu_data" in ret: + ret = ret["cpu_data"] + data = ret if isinstance(ret, list) else [ret] + built = self._build_amdsmi_sub_data(CpuMetric, data) + return built if isinstance(built, list) else ([built] if built else []) + + def get_core_metric(self) -> List[CoreMetric]: + """Get per-core metrics from amd-smi metric --core all --json.""" + ret = self._run_amd_smi_dict(self.CMD_METRIC_CORE) + if ret is None: + return [] + if isinstance(ret, dict) and "core_data" in ret: + ret = ret["core_data"] + data = ret if isinstance(ret, list) else [ret] + built = self._build_amdsmi_sub_data(CoreMetric, data) + return built if isinstance(built, list) else ([built] if built else []) + def _get_amdsmi_data( self, args: Optional[AmdSmiCollectorArgs] = None ) -> Optional[AmdSmiDataModel]: @@ -519,6 +558,9 @@ def _get_amdsmi_data( bad_pages = self.get_bad_pages() xgmi_metric, xgmi_link = self.get_xgmi_data() fabric = self.get_fabric() + node = self.get_node() + cpu_metric = self.get_cpu_metric() + core_metric = self.get_core_metric() cper_data, cper_afids = self.get_cper_data() except Exception as e: self._log_event( @@ -546,6 +588,9 @@ def _get_amdsmi_data( xgmi_metric=xgmi_metric or [], xgmi_link=xgmi_link or [], fabric=fabric or [], + node=node or [], + cpu_metric=cpu_metric or [], + core_metric=core_metric or [], cper_data=cper_data, cper_afids=cper_afids, analysis_firmware_ids=fw_ids, diff --git a/nodescraper/plugins/inband/amdsmi/amdsmidata.py b/nodescraper/plugins/inband/amdsmi/amdsmidata.py index bcc9a6b9..07d8e83a 100644 --- a/nodescraper/plugins/inband/amdsmi/amdsmidata.py +++ b/nodescraper/plugins/inband/amdsmi/amdsmidata.py @@ -25,7 +25,7 @@ ############################################################################### import re from enum import Enum -from typing import Any, Mapping, Optional, Union +from typing import Any, ClassVar, Mapping, Optional, Union from pydantic import ( AliasChoices, @@ -974,6 +974,249 @@ class EccData(BaseModel): )(na_to_none) +def _match_known_rails_case_insensitive(rail_set: frozenset[str], data: Any) -> Any: + """Rename dict keys to their canonical-cased rail name when their upper-cased + form matches an entry in ``rail_set`` (the known, already-uppercase rail names).""" + if not isinstance(data, dict): + return data + out = dict(data) + for key in list(out.keys()): + upper_key = key.upper() + if upper_key in rail_set and upper_key not in out: + out[upper_key] = out.pop(key) + return out + + +class MetricClockRails(BaseModel): + """Enumerated, typed view of the known ``AmdSmiMetric.clock`` rails.""" + + model_config = ConfigDict(extra="allow") + + RAIL_NAMES: ClassVar[tuple[str, ...]] = ( + "GFX_0", + "GFX_1", + "GFX_2", + "GFX_3", + "GFX_4", + "GFX_5", + "GFX_6", + "GFX_7", + "MEM_0", + "VCLK_0", + "VCLK_1", + "VCLK_2", + "VCLK_3", + "DCLK_0", + "DCLK_1", + "DCLK_2", + "DCLK_3", + "SOCCLK_0", + "FCLK_0", + "UCLK_AID", + "SOCCLKS_MID", + ) + _RAIL_SET: ClassVar[frozenset[str]] = frozenset(RAIL_NAMES) + + GFX_0: Optional[MetricClockData] = None + GFX_1: Optional[MetricClockData] = None + GFX_2: Optional[MetricClockData] = None + GFX_3: Optional[MetricClockData] = None + GFX_4: Optional[MetricClockData] = None + GFX_5: Optional[MetricClockData] = None + GFX_6: Optional[MetricClockData] = None + GFX_7: Optional[MetricClockData] = None + MEM_0: Optional[MetricClockData] = None + VCLK_0: Optional[MetricClockData] = None + VCLK_1: Optional[MetricClockData] = None + VCLK_2: Optional[MetricClockData] = None + VCLK_3: Optional[MetricClockData] = None + DCLK_0: Optional[MetricClockData] = None + DCLK_1: Optional[MetricClockData] = None + DCLK_2: Optional[MetricClockData] = None + DCLK_3: Optional[MetricClockData] = None + SOCCLK_0: Optional[MetricClockData] = None + FCLK_0: Optional[MetricClockData] = None + UCLK_AID: Optional[dict[str, Optional[ValueUnit]]] = None + SOCCLKS_MID: Optional[dict[str, Optional[ValueUnit]]] = None + + @model_validator(mode="before") + @classmethod + def _match_rails(cls, data: Any) -> Any: + return _match_known_rails_case_insensitive(cls._RAIL_SET, data) + + @field_validator("UCLK_AID", "SOCCLKS_MID", mode="before") + @classmethod + def _coerce_submap(cls, v: Any) -> Any: + if not isinstance(v, dict): + return v + return {k: coerce_value_unit_input(val) for k, val in v.items()} + + +class MetricGpuBoardTemperature(BaseModel): + """Known ``gpu_board.temperature`` rails (ROCm 7.1+ ``metric`` JSON). + + Each rail is independently optional and N/A-tolerant. + """ + + model_config = ConfigDict(extra="allow") + + RAIL_NAMES: ClassVar[tuple[str, ...]] = ( + "NODE_RETIMER_X", + "NODE_OAM_X_IBC", + "NODE_OAM_X_IBC_2", + "NODE_OAM_X_VDD18_VR", + "NODE_OAM_X_04_HBM_B_VR", + "NODE_OAM_X_04_HBM_D_VR", + "VDDCR_VDD0", + "VDDCR_VDD1", + "VDDCR_VDD2", + "VDDCR_VDD3", + "VDDCR_SOC_A", + "VDDCR_SOC_C", + "VDDCR_SOCIO_A", + "VDDCR_SOCIO_C", + "VDD_085_HBM", + "VDDCR_11_HBM_B", + "VDDCR_11_HBM_D", + "VDD_USR", + "VDDIO_11_E32", + "VDDIO_04_HBM_B", + "VDDIO_04_HBM_D", + "VDDCR_075_HBM_B", + "VDDCR_075_HBM_D", + "VDDIO_11_GTA_A", + "VDDIO_11_GTA_C", + "VDDAN_075_GTA_A", + "VDDAN_075_GTA_C", + "VDDCR_075_UCIE", + "VDDIO_065_UCIEAA", + "VDDIO_065_UCIEAM_A", + "VDDIO_065_UCIEAM_C", + "VDDAN_075", + ) + _RAIL_SET: ClassVar[frozenset[str]] = frozenset(RAIL_NAMES) + + NODE_RETIMER_X: Optional[ValueUnit] = None + NODE_OAM_X_IBC: Optional[ValueUnit] = None + NODE_OAM_X_IBC_2: Optional[ValueUnit] = None + NODE_OAM_X_VDD18_VR: Optional[ValueUnit] = None + NODE_OAM_X_04_HBM_B_VR: Optional[ValueUnit] = None + NODE_OAM_X_04_HBM_D_VR: Optional[ValueUnit] = None + VDDCR_VDD0: Optional[ValueUnit] = None + VDDCR_VDD1: Optional[ValueUnit] = None + VDDCR_VDD2: Optional[ValueUnit] = None + VDDCR_VDD3: Optional[ValueUnit] = None + VDDCR_SOC_A: Optional[ValueUnit] = None + VDDCR_SOC_C: Optional[ValueUnit] = None + VDDCR_SOCIO_A: Optional[ValueUnit] = None + VDDCR_SOCIO_C: Optional[ValueUnit] = None + VDD_085_HBM: Optional[ValueUnit] = None + VDDCR_11_HBM_B: Optional[ValueUnit] = None + VDDCR_11_HBM_D: Optional[ValueUnit] = None + VDD_USR: Optional[ValueUnit] = None + VDDIO_11_E32: Optional[ValueUnit] = None + VDDIO_04_HBM_B: Optional[ValueUnit] = None + VDDIO_04_HBM_D: Optional[ValueUnit] = None + VDDCR_075_HBM_B: Optional[ValueUnit] = None + VDDCR_075_HBM_D: Optional[ValueUnit] = None + VDDIO_11_GTA_A: Optional[ValueUnit] = None + VDDIO_11_GTA_C: Optional[ValueUnit] = None + VDDAN_075_GTA_A: Optional[ValueUnit] = None + VDDAN_075_GTA_C: Optional[ValueUnit] = None + VDDCR_075_UCIE: Optional[ValueUnit] = None + VDDIO_065_UCIEAA: Optional[ValueUnit] = None + VDDIO_065_UCIEAM_A: Optional[ValueUnit] = None + VDDIO_065_UCIEAM_C: Optional[ValueUnit] = None + VDDAN_075: Optional[ValueUnit] = None + + _rail_value_unit = field_validator(*RAIL_NAMES, mode="before")(coerce_value_unit_input) + + @model_validator(mode="before") + @classmethod + def _match_rails(cls, data: Any) -> Any: + return _match_known_rails_case_insensitive(cls._RAIL_SET, data) + + +class MetricGpuBoard(BaseModel): + model_config = ConfigDict(extra="allow") + + temperature: Optional[MetricGpuBoardTemperature] = None + + na_validator = field_validator("temperature", mode="before")(na_to_none) + + +class MetricBaseBoardTemperature(BaseModel): + """Known ``base_board.temperature`` (UBB) rails (ROCm 7.1+ ``metric`` JSON).""" + + model_config = ConfigDict(extra="allow") + + RAIL_NAMES: ClassVar[tuple[str, ...]] = ( + "UBB_FPGA", + "UBB_FRONT", + "UBB_BACK", + "UBB_OAM1", + "UBB_OAM7", + "UBB_IBC", + "UBB_UFPGA", + "UBB_FPGA_0V72_VR", + "UBB_FPGA_3V3_VR", + "OAM_0_1_HSC", + "OAM_2_3_HSC", + "OAM_4_5_HSC", + "OAM_6_7_HSC", + "OAM_0_1_2_3_3V3_VR", + "OAM_4_5_6_7_3V3_VR", + "IBC", + "IBC_HSC", + "RETIMER_0_1_0V9_VR", + "RETIMER_2_3_0V9_VR", + "RETIMER_4_5_0V9_VR", + "RETIMER_6_7_0V9_VR", + "RETIMER_0_1_2_3_1V2_VR", + "RETIMER_4_5_6_7_1V2_VR", + ) + _RAIL_SET: ClassVar[frozenset[str]] = frozenset(RAIL_NAMES) + + UBB_FPGA: Optional[ValueUnit] = None + UBB_FRONT: Optional[ValueUnit] = None + UBB_BACK: Optional[ValueUnit] = None + UBB_OAM1: Optional[ValueUnit] = None + UBB_OAM7: Optional[ValueUnit] = None + UBB_IBC: Optional[ValueUnit] = None + UBB_UFPGA: Optional[ValueUnit] = None + UBB_FPGA_0V72_VR: Optional[ValueUnit] = None + UBB_FPGA_3V3_VR: Optional[ValueUnit] = None + OAM_0_1_HSC: Optional[ValueUnit] = None + OAM_2_3_HSC: Optional[ValueUnit] = None + OAM_4_5_HSC: Optional[ValueUnit] = None + OAM_6_7_HSC: Optional[ValueUnit] = None + OAM_0_1_2_3_3V3_VR: Optional[ValueUnit] = None + OAM_4_5_6_7_3V3_VR: Optional[ValueUnit] = None + IBC: Optional[ValueUnit] = None + IBC_HSC: Optional[ValueUnit] = None + RETIMER_0_1_0V9_VR: Optional[ValueUnit] = None + RETIMER_2_3_0V9_VR: Optional[ValueUnit] = None + RETIMER_4_5_0V9_VR: Optional[ValueUnit] = None + RETIMER_6_7_0V9_VR: Optional[ValueUnit] = None + RETIMER_0_1_2_3_1V2_VR: Optional[ValueUnit] = None + RETIMER_4_5_6_7_1V2_VR: Optional[ValueUnit] = None + + _rail_value_unit = field_validator(*RAIL_NAMES, mode="before")(coerce_value_unit_input) + + @model_validator(mode="before") + @classmethod + def _match_rails(cls, data: Any) -> Any: + return _match_known_rails_case_insensitive(cls._RAIL_SET, data) + + +class MetricBaseBoard(BaseModel): + model_config = ConfigDict(extra="allow") + + temperature: Optional[MetricBaseBoardTemperature] = None + + na_validator = field_validator("temperature", mode="before")(na_to_none) + + class AmdSmiMetric(BaseModel): model_config = ConfigDict(extra="ignore", populate_by_name=True) @@ -992,11 +1235,11 @@ class AmdSmiMetric(BaseModel): energy: Optional[MetricEnergy] mem_usage: MetricMemUsage throttle: MetricThrottle - gpuboard: Optional[Union[dict[str, Any], str]] = Field( + gpuboard: Optional[Union[MetricGpuBoard, str]] = Field( default=None, validation_alias=AliasChoices("gpuboard", "gpu_board"), ) - baseboard: Optional[Union[dict[str, Any], str]] = Field( + baseboard: Optional[Union[MetricBaseBoard, str]] = Field( default=None, validation_alias=AliasChoices("baseboard", "base_board"), ) @@ -1009,6 +1252,11 @@ class AmdSmiMetric(BaseModel): def _normalize_clock(cls, clock: Any) -> Any: return _normalize_metric_clock_map(clock) + @computed_field + def clock_rails(self) -> MetricClockRails: + """Enumerated view of clock catching a dropped/renamed known rail.""" + return MetricClockRails.model_validate(self.clock or {}) + @field_validator("ecc_blocks", mode="before") @classmethod def validate_ecc_blocks(cls, value: Union[dict[str, EccData], str]) -> dict[str, EccData]: @@ -1203,6 +1451,299 @@ class Fabric(BaseModel): na_validator = field_validator("bdf", "fabric_info", mode="before")(na_to_none) +# NODE + + +class NodePowerManagement(BaseModel): + """``node[].node.power_management`` (NODE_TOTAL_POWER).""" + + model_config = ConfigDict(extra="allow") + + limit: Optional[ValueUnit] = None + status: Optional[str] = None + threshold: Optional[ValueUnit] = None + + na_validator = field_validator("status", mode="before")(na_to_none) + _value_unit = field_validator("limit", "threshold", mode="before")(coerce_value_unit_input) + + +class NodeGtt(BaseModel): + """``node[].node.gtt`` (GTT_SIZE). Plain numbers, not value+unit dicts.""" + + model_config = ConfigDict(extra="allow") + + size_gb: Optional[float] = None + size_pages: Optional[float] = None + + na_validator = field_validator("size_gb", "size_pages", mode="before")(na_to_none) + + +class NodeBaseBoard(BaseModel): + """``node[].node.base_board`` (named chassis temperature sensors).""" + + model_config = ConfigDict(extra="allow") + + temperature: dict[str, ValueUnit] = Field(default_factory=dict) + + _value_unit = field_validator("temperature", mode="before")( + lambda v: ( + {k: coerce_value_unit_input(val) for k, val in v.items()} if isinstance(v, dict) else v + ) + ) + + +class NodeData(BaseModel): + model_config = ConfigDict(extra="allow") + + power_management: Optional[NodePowerManagement] = None + base_board: Optional[NodeBaseBoard] = None + gtt: Optional[NodeGtt] = None + + +class NodeInfo(BaseModel): + """One entry from ``amd-smi node --json``: a top-level array of + ``{"node": {...}}`` objects.""" + + model_config = ConfigDict(extra="allow") + + node: Optional[NodeData] = None + + +# CPU / CORE + + +class CoreMetric(BaseModel): + """Per-core data from ``amd-smi metric --core all --json``.""" + + model_config = ConfigDict(extra="allow") + + core: int + boost_limit: Optional[ValueUnit] = None + curr_active_freq_core_limit: Optional[ValueUnit] = None + core_energy: Optional[ValueUnit] = None + ccd_power: Optional[ValueUnit] = None + floor_limit: Optional[ValueUnit] = None + eff_floor_limit: Optional[ValueUnit] = None + + _value_unit = field_validator( + "boost_limit", + "curr_active_freq_core_limit", + "core_energy", + "ccd_power", + "floor_limit", + "eff_floor_limit", + mode="before", + )(coerce_value_unit_input) + + +class CpuMetricPower(BaseModel): + """``power_metrics`` group; JSON keys have literal spaces, not underscores.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + socket_power: Optional[ValueUnit] = Field(default=None, alias="socket power") + socket_power_limit: Optional[ValueUnit] = Field(default=None, alias="socket power limit") + socket_max_power_limit: Optional[ValueUnit] = Field( + default=None, alias="socket max power limit" + ) + + _value_unit = field_validator( + "socket_power", "socket_power_limit", "socket_max_power_limit", mode="before" + )(coerce_value_unit_input) + + +class CpuMetricProchot(BaseModel): + model_config = ConfigDict(extra="allow") + + prochot_status: Optional[int] = None + + +class CpuMetricFreqFclkMemclk(BaseModel): + model_config = ConfigDict(extra="allow") + + fclk: Optional[ValueUnit] = None + mclk: Optional[ValueUnit] = None + + _value_unit = field_validator("fclk", "mclk", mode="before")(coerce_value_unit_input) + + +class CpuMetricFreqActiveLimit(BaseModel): + model_config = ConfigDict(extra="allow") + + freq: Optional[ValueUnit] = None + freq_src: Optional[str] = None + + _value_unit = field_validator("freq", mode="before")(coerce_value_unit_input) + + +class CpuMetricFreqRange(BaseModel): + model_config = ConfigDict(extra="allow") + + max_socket_freq: Optional[ValueUnit] = None + min_socket_freq: Optional[ValueUnit] = None + + _value_unit = field_validator("max_socket_freq", "min_socket_freq", mode="before")( + coerce_value_unit_input + ) + + +class CpuMetricFreq(BaseModel): + """``freq_metrics`` group""" + + model_config = ConfigDict(extra="allow") + + fclkmemclk: Optional[CpuMetricFreqFclkMemclk] = None + cclkfreqlimit: Optional[ValueUnit] = None + soc_current_active_freq_limit: Optional[CpuMetricFreqActiveLimit] = None + soc_freq_range: Optional[CpuMetricFreqRange] = None + + _value_unit = field_validator("cclkfreqlimit", mode="before")(coerce_value_unit_input) + + +class CpuMetricC0Residency(BaseModel): + model_config = ConfigDict(extra="allow") + + residency: Optional[ValueUnit] = None + + _value_unit = field_validator("residency", mode="before")(coerce_value_unit_input) + + +class CpuMetricSviTelemetry(BaseModel): + model_config = ConfigDict(extra="allow") + + power: Optional[ValueUnit] = None + + _value_unit = field_validator("power", mode="before")(coerce_value_unit_input) + + +class CpuMetricPwrEffMode(BaseModel): + model_config = ConfigDict(extra="allow") + + mode: Optional[str] = None + + +class CpuMetricVersion(BaseModel): + model_config = ConfigDict(extra="allow") + + version: Optional[int] = None + + +class CpuMetricTable(BaseModel): + model_config = ConfigDict(extra="allow") + + cpu_family: Optional[int] = None + cpu_model: Optional[int] = None + response: Optional[ValueUnit] = None + + _value_unit = field_validator("response", mode="before")(coerce_value_unit_input) + + +class CpuMetricSocketEnergy(BaseModel): + model_config = ConfigDict(extra="allow") + + response: Optional[ValueUnit] = None + + _value_unit = field_validator("response", mode="before")(coerce_value_unit_input) + + +class CpuMetricDdrBandwidthResponse(BaseModel): + model_config = ConfigDict(extra="allow") + + ddr_bw_max_bw: Optional[ValueUnit] = None + ddr_bw_utilized_bw: Optional[ValueUnit] = None + ddr_bw_utilized_pct: Optional[ValueUnit] = None + + _value_unit = field_validator( + "ddr_bw_max_bw", "ddr_bw_utilized_bw", "ddr_bw_utilized_pct", mode="before" + )(coerce_value_unit_input) + + +class CpuMetricDdrBandwidth(BaseModel): + """``ddr_bandwidth`` nests its payload one level deeper than the other + ``response``-wrapped groups""" + + model_config = ConfigDict(extra="allow") + + response: Optional[CpuMetricDdrBandwidthResponse] = None + + na_validator = field_validator("response", mode="before")(na_to_none) + + +class CpuMetricCpuTemp(BaseModel): + model_config = ConfigDict(extra="allow") + + response: Optional[ValueUnit] = None + + _value_unit = field_validator("response", mode="before")(coerce_value_unit_input) + + +class CpuMetricXgmiPstateRange(BaseModel): + model_config = ConfigDict(extra="allow") + + min_pstate: Optional[int] = None + max_pstate: Optional[int] = None + + na_validator = field_validator("min_pstate", "max_pstate", mode="before")(na_to_none) + + +class CpuMetricEnabledCommands(BaseModel): + model_config = ConfigDict(extra="allow") + + READ_ENABLED_COMMANDS_BITMASK0: Optional[Union[int, str]] = None + READ_ENABLED_COMMANDS_BITMASK1: Optional[Union[int, str]] = None + READ_ENABLED_COMMANDS_BITMASK2: Optional[Union[int, str]] = None + WRITE_ENABLED_COMMANDS_BITMASK0: Optional[Union[int, str]] = None + WRITE_ENABLED_COMMANDS_BITMASK1: Optional[Union[int, str]] = None + WRITE_ENABLED_COMMANDS_BITMASK2: Optional[Union[int, str]] = None + + na_validator = field_validator( + "READ_ENABLED_COMMANDS_BITMASK0", + "READ_ENABLED_COMMANDS_BITMASK1", + "READ_ENABLED_COMMANDS_BITMASK2", + "WRITE_ENABLED_COMMANDS_BITMASK0", + "WRITE_ENABLED_COMMANDS_BITMASK1", + "WRITE_ENABLED_COMMANDS_BITMASK2", + mode="before", + )(na_to_none) + + +class CpuMetric(BaseModel): + """Per-CPU-socket data from ``amd-smi metric --cpu all --json``.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + cpu: int + power_metrics: Optional[CpuMetricPower] = None + prochot: Optional[CpuMetricProchot] = None + freq_metrics: Optional[CpuMetricFreq] = None + c0_residency: Optional[CpuMetricC0Residency] = None + svi_telemetry_all_rails: Optional[CpuMetricSviTelemetry] = None + pwr_eff_mode: Optional[CpuMetricPwrEffMode] = None + metric_version: Optional[CpuMetricVersion] = None + metrics_table: Optional[CpuMetricTable] = None + socket_energy: Optional[CpuMetricSocketEnergy] = None + ddr_bandwidth: Optional[CpuMetricDdrBandwidth] = None + cpu_temp: Optional[CpuMetricCpuTemp] = None + xgmi_pstate_range: Optional[CpuMetricXgmiPstateRange] = None + railisofreq_policy: Optional[ValueUnit] = None + dfcstate_ctrl: Optional[ValueUnit] = None + pc6_enable: Optional[ValueUnit] = None + cc6_enable: Optional[ValueUnit] = None + tdelta: Optional[ValueUnit] = None + enabled_commands: Optional[CpuMetricEnabledCommands] = None + sdps_limit: Optional[ValueUnit] = None + + _value_unit = field_validator( + "railisofreq_policy", + "dfcstate_ctrl", + "pc6_enable", + "cc6_enable", + "tdelta", + "sdps_limit", + mode="before", + )(coerce_value_unit_input) + + class AmdSmiAnalysisRef(BaseModel): """Collector-filled summary for reference config""" @@ -1249,6 +1790,9 @@ class AmdSmiDataModel(DataModel): xgmi_metric: Optional[list[XgmiMetrics]] = Field(default_factory=list) xgmi_link: Optional[list[XgmiLinks]] = Field(default_factory=list) fabric: Optional[list[Fabric]] = Field(default_factory=list) + node: Optional[list[NodeInfo]] = Field(default_factory=list) + cpu_metric: Optional[list[CpuMetric]] = Field(default_factory=list) + core_metric: Optional[list[CoreMetric]] = Field(default_factory=list) cper_data: Optional[list[FileModel]] = Field(default_factory=list) cper_afids: dict[str, int] = Field(default_factory=dict) diff --git a/test/unit/plugin/test_amdsmi_collector.py b/test/unit/plugin/test_amdsmi_collector.py index a2d49738..85988d3e 100644 --- a/test/unit/plugin/test_amdsmi_collector.py +++ b/test/unit/plugin/test_amdsmi_collector.py @@ -988,3 +988,248 @@ def test_get_fabric_na_values(conn_mock, system_info, monkeypatch): assert len(fabric) == 1 assert fabric[0].bdf is None assert fabric[0].fabric_info is None + + +# NODE + + +def node_json_entry(include_gtt: bool = True) -> dict[str, Any]: + """Build one dummy `amd-smi node --json` entry as emitted by the tool.""" + node: dict[str, Any] = { + "power_management": {"limit": "N/A", "status": "N/A", "threshold": "N/A"}, + } + if include_gtt: + node["gtt"] = {"size_gb": 100.0, "size_pages": 1000} + return {"node": node} + + +def make_node_collector(conn_mock, system_info, monkeypatch, node_payload) -> AmdSmiCollector: + """Create a collector whose only mocked amd-smi command is `node`.""" + + def mock_run_sut_cmd(cmd: str, sudo: bool = False) -> MagicMock: + if "which amd-smi" in cmd: + return make_cmd_result("/usr/bin/amd-smi") + if "node --json" in cmd: + if node_payload is None: + return make_cmd_result("", "node not supported", 1) + return make_cmd_result(make_json_response(node_payload)) + return make_cmd_result("") + + c = AmdSmiCollector( + system_info=system_info, + system_interaction_level=SystemInteractionLevel.PASSIVE, + connection=conn_mock, + ) + monkeypatch.setattr(c, "_run_sut_cmd", mock_run_sut_cmd) + return c + + +def test_get_node(conn_mock, system_info, monkeypatch): + """Test node parsing from the {"node": {...}} array shape.""" + payload = [node_json_entry()] + c = make_node_collector(conn_mock, system_info, monkeypatch, payload) + + node = c.get_node() + + assert len(node) == 1 + assert node[0].node.gtt.size_gb == 100.0 + assert node[0].node.gtt.size_pages == 1000 + assert node[0].node.power_management.limit is None + assert node[0].node.power_management.status is None + assert node[0].node.power_management.threshold is None + + +def test_get_node_missing_gtt(conn_mock, system_info, monkeypatch): + """gtt may be entirely absent from a node entry.""" + payload = [node_json_entry(include_gtt=False)] + c = make_node_collector(conn_mock, system_info, monkeypatch, payload) + + node = c.get_node() + + assert len(node) == 1 + assert node[0].node.gtt is None + + +def test_get_node_command_failure(conn_mock, system_info, monkeypatch): + """Test get_node returns an empty list when the command fails.""" + c = make_node_collector(conn_mock, system_info, monkeypatch, None) + + assert c.get_node() == [] + + +def test_get_node_real_hardware_shape(conn_mock, system_info, monkeypatch): + """Real amd-smi reports power_management.limit/threshold as value+unit dicts + (not bare numbers/"N/A" strings), and includes a base_board.temperature block.""" + payload = [ + { + "node": { + "power_management": { + "limit": {"value": 0, "unit": "W"}, + "status": "DISABLED", + "threshold": "N/A", + }, + "base_board": { + "temperature": { + "UBB_FPGA": {"value": -1, "unit": "°C"}, + "IBC": {"value": -1, "unit": "°C"}, + } + }, + "gtt": {"size_gb": 256.000, "size_pages": 65900000}, + } + } + ] + c = make_node_collector(conn_mock, system_info, monkeypatch, payload) + + node = c.get_node() + + assert len(node) == 1 + pm = node[0].node.power_management + assert pm.limit.value == 0 + assert pm.limit.unit == "W" + assert pm.status == "DISABLED" + assert pm.threshold is None + + temps = node[0].node.base_board.temperature + assert temps["UBB_FPGA"].value == -1 + assert temps["UBB_FPGA"].unit == "°C" + assert temps["IBC"].value == -1 + + +# CPU / CORE METRICS + + +def cpu_metric_json_entry(cpu: int = 0) -> dict[str, Any]: + """Build one dummy `amd-smi metric --cpu all --json` entry as emitted by the tool.""" + return { + "cpu": cpu, + "power_metrics": { + "socket power": "100.000 W", + "socket power limit": "200.000 W", + "socket max power limit": "200.000 W", + }, + "prochot": {"prochot_status": 0}, + "freq_metrics": { + "fclkmemclk": {"fclk": "1000 MHz", "mclk": "2000 MHz"}, + "cclkfreqlimit": "2000 MHz", + "soc_current_active_freq_limit": {"freq": "2000 MHz", "freq_src": "['Example']"}, + "soc_freq_range": {"max_socket_freq": "2000 MHz", "min_socket_freq": "500 MHz"}, + }, + "c0_residency": {"residency": "1 %"}, + "svi_telemetry_all_rails": {"power": "1000 mW"}, + "pwr_eff_mode": {"mode": "0"}, + "metric_version": {"version": 1}, + "metrics_table": {"cpu_family": 1, "cpu_model": 1, "response": "N/A"}, + "socket_energy": {"response": "1000.0 J"}, + "ddr_bandwidth": { + "response": { + "ddr_bw_max_bw": "100 Gbps", + "ddr_bw_utilized_bw": "0 Gbps", + "ddr_bw_utilized_pct": "0 %", + } + }, + "cpu_temp": {"response": "N/A"}, + "xgmi_pstate_range": {"min_pstate": "N/A", "max_pstate": "N/A"}, + "railisofreq_policy": {"value": 0}, + "dfcstate_ctrl": {"value": 1}, + "pc6_enable": {"value": "N/A"}, + "cc6_enable": {"value": "N/A"}, + "tdelta": {"value": "N/A"}, + "enabled_commands": { + "READ_ENABLED_COMMANDS_BITMASK0": "N/A", + "READ_ENABLED_COMMANDS_BITMASK1": "N/A", + "READ_ENABLED_COMMANDS_BITMASK2": "N/A", + "WRITE_ENABLED_COMMANDS_BITMASK0": "N/A", + "WRITE_ENABLED_COMMANDS_BITMASK1": "N/A", + "WRITE_ENABLED_COMMANDS_BITMASK2": "N/A", + }, + "sdps_limit": {"value": "N/A"}, + } + + +def core_metric_json_entry(core: int = 0) -> dict[str, Any]: + """Build a `amd-smi metric --core all --json` entry as emitted by the tool.""" + return { + "core": core, + "boost_limit": {"value": 2000}, + "curr_active_freq_core_limit": {"value": "2000 MHz"}, + "core_energy": {"value": "N/A"}, + "ccd_power": {"value": "N/A"}, + "floor_limit": {"value": "N/A"}, + "eff_floor_limit": {"value": "N/A"}, + } + + +def make_cpu_core_collector( + conn_mock, system_info, monkeypatch, cpu_payload=None, core_payload=None +) -> AmdSmiCollector: + """Create a collector with metric --cpu/--core all commands.""" + + def mock_run_sut_cmd(cmd: str, sudo: bool = False) -> MagicMock: + if "which amd-smi" in cmd: + return make_cmd_result("/usr/bin/amd-smi") + if "metric --cpu all --json" in cmd: + if cpu_payload is None: + return make_cmd_result("", "cpu metrics not supported", 1) + return make_cmd_result(make_json_response(cpu_payload)) + if "metric --core all --json" in cmd: + if core_payload is None: + return make_cmd_result("", "core metrics not supported", 1) + return make_cmd_result(make_json_response(core_payload)) + return make_cmd_result("") + + c = AmdSmiCollector( + system_info=system_info, + system_interaction_level=SystemInteractionLevel.PASSIVE, + connection=conn_mock, + ) + monkeypatch.setattr(c, "_run_sut_cmd", mock_run_sut_cmd) + return c + + +def test_get_cpu_metric(conn_mock, system_info, monkeypatch): + """Test CPU metric parsing from the amd-smi metric --cpu all --json shape.""" + payload = {"cpu_data": [cpu_metric_json_entry()]} + c = make_cpu_core_collector(conn_mock, system_info, monkeypatch, cpu_payload=payload) + + cpu_metric = c.get_cpu_metric() + + assert len(cpu_metric) == 1 + entry = cpu_metric[0] + assert entry.cpu == 0 + assert entry.power_metrics.socket_power.value == 100.0 + assert entry.power_metrics.socket_power_limit.unit == "W" + assert entry.prochot.prochot_status == 0 + assert entry.freq_metrics.fclkmemclk.fclk.value == 1000 + assert entry.ddr_bandwidth.response.ddr_bw_max_bw.value == 100 + assert entry.cpu_temp.response is None + assert entry.sdps_limit is None + assert entry.xgmi_pstate_range.min_pstate is None + + +def test_get_cpu_metric_command_failure(conn_mock, system_info, monkeypatch): + """Test get_cpu_metric returns an empty list when the command fails.""" + c = make_cpu_core_collector(conn_mock, system_info, monkeypatch, cpu_payload=None) + + assert c.get_cpu_metric() == [] + + +def test_get_core_metric(conn_mock, system_info, monkeypatch): + """Test core metric parsing from the amd-smi metric --core all --json shape.""" + payload = {"core_data": [core_metric_json_entry(), core_metric_json_entry(core=1)]} + c = make_cpu_core_collector(conn_mock, system_info, monkeypatch, core_payload=payload) + + core_metric = c.get_core_metric() + + assert [entry.core for entry in core_metric] == [0, 1] + assert core_metric[0].boost_limit.value == 2000 + assert core_metric[0].curr_active_freq_core_limit.unit == "MHz" + assert core_metric[0].core_energy is None + assert core_metric[0].ccd_power is None + assert core_metric[0].floor_limit is None + + +def test_get_core_metric_command_failure(conn_mock, system_info, monkeypatch): + """Test get_core_metric returns an empty list when the command fails.""" + c = make_cpu_core_collector(conn_mock, system_info, monkeypatch, core_payload=None) + + assert c.get_core_metric() == [] diff --git a/test/unit/plugin/test_amdsmi_data.py b/test/unit/plugin/test_amdsmi_data.py index 60120799..4a2bf4fb 100644 --- a/test/unit/plugin/test_amdsmi_data.py +++ b/test/unit/plugin/test_amdsmi_data.py @@ -34,7 +34,10 @@ AmdSmiMetric, Fabric, FabricInfo, + MetricBaseBoardTemperature, MetricClockData, + MetricClockRails, + MetricGpuBoardTemperature, MetricPcie, MetricPower, StaticClockData, @@ -491,15 +494,18 @@ def test_metric_usage_string_na(): def test_metric_gpuboard_baseboard_optional(): - """Store gpuboard/baseboard sections from ROCm 7.1+ metric output.""" + """Store gpuboard/baseboard sections from ROCm 7.1+ metric output as typed models.""" metric = AmdSmiMetric.model_validate( dummy_metric_dict( gpuboard=DUMMY_METRIC_GPUBOARD, baseboard=DUMMY_METRIC_BASEBOARD, ) ) - assert metric.gpuboard == DUMMY_METRIC_GPUBOARD - assert metric.baseboard == DUMMY_METRIC_BASEBOARD + assert metric.gpuboard.temperature.NODE_RETIMER_X.value == 43 + assert metric.gpuboard.temperature.NODE_OAM_X_IBC.value == 53 + assert metric.gpuboard.temperature.VDDCR_VDD0 is None + assert metric.baseboard.temperature.UBB_FRONT.value == 55 + assert metric.baseboard.temperature.UBB_FPGA.value == 78 def test_metric_gpuboard_gpu_board_alias(): @@ -512,8 +518,62 @@ def test_metric_gpuboard_gpu_board_alias(): } ) ) - assert metric.gpuboard == DUMMY_METRIC_GPUBOARD - assert metric.baseboard == DUMMY_METRIC_BASEBOARD + assert metric.gpuboard.temperature.NODE_RETIMER_X.value == 43 + assert metric.baseboard.temperature.UBB_FRONT.value == 55 + + +def test_metric_gpuboard_all_rails_present(): + """Every known gpuboard rail is parsed when present, numeric or N/A.""" + sample_payload = { + name: (0 if i % 2 == 0 else "N/A") + for i, name in enumerate(MetricGpuBoardTemperature.RAIL_NAMES) + } + board = MetricGpuBoardTemperature.model_validate(sample_payload) + for i, name in enumerate(MetricGpuBoardTemperature.RAIL_NAMES): + rail = getattr(board, name) + if i % 2 == 0: + assert rail is not None and rail.value == 0 + else: + assert rail is None + + +def test_metric_gpuboard_rail_renamed_or_dropped_is_none_not_absorbed(): + """A rail missing/renamed from the payload surfaces as None on its named field.""" + board = MetricGpuBoardTemperature.model_validate({"NODE_RETIMER_X": {"value": 5, "unit": "C"}}) + assert board.NODE_RETIMER_X.value == 5 + assert board.VDDCR_VDD0 is None + assert board.VDD_085_HBM is None + + +def test_metric_gpuboard_unknown_rail_tolerated(): + """An unrecognized rail (future FW addition) does not fail validation.""" + board = MetricGpuBoardTemperature.model_validate( + {"NODE_RETIMER_X": {"value": 1, "unit": "C"}, "SOME_NEW_RAIL": {"value": 2, "unit": "C"}} + ) + assert board.NODE_RETIMER_X.value == 1 + + +def test_metric_baseboard_rail_na_and_zero(): + """baseboard rails independently accept N/A and 0 without erroring.""" + board = MetricBaseBoardTemperature.model_validate({"UBB_FRONT": "N/A", "UBB_FPGA": 0}) + assert board.UBB_FRONT is None + assert board.UBB_FPGA.value == 0 + + +def test_metric_gpuboard_rail_case_insensitive_match(): + """Current amd-smi builds emit lowercase rail names (e.g. node_retimer_x).""" + board = MetricGpuBoardTemperature.model_validate({"node_retimer_x": {"value": 43, "unit": "C"}}) + assert board.NODE_RETIMER_X is not None + assert board.NODE_RETIMER_X.value == 43 + assert board.model_extra == {} + + +def test_metric_baseboard_rail_case_insensitive_match(): + """Current amd-smi builds emit lowercase rail names (e.g. ubb_front).""" + board = MetricBaseBoardTemperature.model_validate({"ubb_front": {"value": 55, "unit": "C"}}) + assert board.UBB_FRONT is not None + assert board.UBB_FRONT.value == 55 + assert board.model_extra == {} def test_metric_clock_per_aid_na_maps(): @@ -545,6 +605,40 @@ def test_metric_clock_per_aid_na_maps(): assert isinstance(metric.clock["GFX_0"], MetricClockData) assert metric.pcie.lc_perf_other_end_recovery_count == 0 + assert metric.clock_rails.UCLK_AID == {"AID_0": None, "AID_1": None} + assert metric.clock_rails.SOCCLKS_MID == {"MID_0": None, "MID_1": None} + + +def test_metric_clock_rails_enumerates_known_rails_and_catches_drop(): + """clock_rails exposes every known rail by name; a dropped one reads None.""" + metric = AmdSmiMetric.model_validate(dummy_metric_dict()) + assert isinstance(metric.clock_rails.GFX_0, MetricClockData) + assert metric.clock_rails.GFX_0.clk.value == 132 + assert metric.clock_rails.MEM_0 is None + assert metric.clock_rails.VCLK_0 is None + assert metric.clock_rails.FCLK_0 is None + assert metric.clock_rails.UCLK_AID is None + assert metric.clock_rails.SOCCLKS_MID is None + + +def test_metric_clock_rails_case_insensitive_match(): + """Current amd-smi builds emit lowercase rail names (e.g. gfx_0, mem_0).""" + rails = MetricClockRails.model_validate( + { + "gfx_0": {"clk": {"value": 2353, "unit": "MHz"}}, + "mem_0": {"clk": {"value": 1200, "unit": "MHz"}}, + "uclk_aid": {"AID_0": "N/A", "AID_1": {"value": 2000, "unit": "MHz"}}, + } + ) + assert rails.GFX_0 is not None + assert rails.GFX_0.clk.value == 2353 + assert rails.UCLK_AID["AID_0"] is None + assert rails.UCLK_AID["AID_1"].value == 2000 + assert rails.UCLK_AID["AID_1"].unit == "MHz" + assert rails.MEM_0 is not None + assert rails.MEM_0.clk.value == 1200 + assert rails.model_extra == {} + # FABRIC