diff --git a/.github/workflows/publish-to-test-pypi.yml b/.github/workflows/publish-to-test-pypi.yml index 7d05d25c..a236156a 100644 --- a/.github/workflows/publish-to-test-pypi.yml +++ b/.github/workflows/publish-to-test-pypi.yml @@ -19,7 +19,7 @@ jobs: with: miniconda-version: "latest" activate-environment: tkml - python-version: ${{ matrix.python-version }} + python-version: "3.10" - name: Install pypa/build run: >- python -m pip install build --user diff --git a/README.md b/README.md index 534dfd58..a1d78cf2 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,13 @@ All build results, such as `.onnx` files, are collected into a cache directory, `turnkey` collects statistics about each model and build into the corresponding build directory in the cache. Use `turnkey report -h` to see how those statistics can be exported into a CSV file. +### System Information + +System information for the current `turnkey` installation is collected and viewed with the `system-info` management tool: + +``` +> turnkey system-info +``` ## Extensibility diff --git a/setup.py b/setup.py index dff20652..77d17567 100644 --- a/setup.py +++ b/setup.py @@ -44,6 +44,7 @@ "fasteners", "GitPython>=3.1.40", "psutil", + "wmi", # Conditional dependencies for ONNXRuntime backends "onnxruntime >=1.10.1;platform_system=='Linux' and extra != 'llm-oga-cuda'", "onnxruntime-directml >=1.19.0;platform_system=='Windows' and extra != 'llm-oga-cuda'", diff --git a/src/turnkeyml/common/build.py b/src/turnkeyml/common/build.py index b90feb8e..5637836b 100644 --- a/src/turnkeyml/common/build.py +++ b/src/turnkeyml/common/build.py @@ -1,20 +1,15 @@ import os import logging import sys -import shutil import traceback -import platform -import subprocess from typing import Dict, Union import hashlib -import pkg_resources import psutil import yaml import torch import numpy as np import turnkeyml.common.exceptions as exp - UnionValidModelInstanceTypes = Union[ None, str, @@ -197,7 +192,7 @@ def get_shapes_and_dtypes(inputs: dict): class Logger: """ - Redirects stdout to to file (and console if needed) + Redirects stdout to file (and console if needed) """ def __init__( @@ -266,118 +261,3 @@ def write(self, message): def flush(self): # needed for python 3 compatibility. pass - - -def get_system_info(): - os_type = platform.system() - info_dict = {} - - # Get OS Version - try: - info_dict["OS Version"] = platform.platform() - except Exception as e: # pylint: disable=broad-except - info_dict["Error OS Version"] = str(e) - - def get_wmic_info(command): - try: - output = subprocess.check_output(command, shell=True).decode() - return output.split("\n")[1].strip() - except Exception as e: # pylint: disable=broad-except - return str(e) - - if os_type == "Windows": - if shutil.which("wmic") is not None: - info_dict["Processor"] = get_wmic_info("wmic cpu get name") - info_dict["OEM System"] = get_wmic_info("wmic computersystem get model") - mem_info_bytes = get_wmic_info( - "wmic computersystem get TotalPhysicalMemory" - ) - try: - mem_info_gb = round(int(mem_info_bytes) / (1024**3), 2) - info_dict["Physical Memory"] = f"{mem_info_gb} GB" - except ValueError: - info_dict["Physical Memory"] = mem_info_bytes - else: - info_dict["Processor"] = "Install WMIC to get system info" - info_dict["OEM System"] = "Install WMIC to get system info" - info_dict["Physical Memory"] = "Install WMIC to get system info" - - elif os_type == "Linux": - # WSL has to be handled differently compared to native Linux - if "microsoft" in str(platform.release()): - try: - oem_info = ( - subprocess.check_output( - 'powershell.exe -Command "wmic computersystem get model"', - shell=True, - ) - .decode() - .strip() - ) - oem_info = ( - oem_info.replace("\r", "") - .replace("\n", "") - .split("Model")[-1] - .strip() - ) - info_dict["OEM System"] = oem_info - except Exception as e: # pylint: disable=broad-except - info_dict["Error OEM System (WSL)"] = str(e) - - else: - # Get OEM System Information - try: - oem_info = ( - subprocess.check_output( - "sudo -n dmidecode -s system-product-name", - shell=True, - stderr=subprocess.DEVNULL, - ) - .decode() - .strip() - .replace("\n", " ") - ) - info_dict["OEM System"] = oem_info - except subprocess.CalledProcessError: - # This catches the case where sudo requires a password - info_dict["OEM System"] = "Unable to get oem info - password required" - except Exception as e: # pylint: disable=broad-except - info_dict["Error OEM System"] = str(e) - - # Get CPU Information - try: - cpu_info = subprocess.check_output("lscpu", shell=True).decode() - for line in cpu_info.split("\n"): - if "Model name:" in line: - info_dict["Processor"] = line.split(":")[1].strip() - break - except Exception as e: # pylint: disable=broad-except - info_dict["Error Processor"] = str(e) - - # Get Memory Information - try: - mem_info = ( - subprocess.check_output("free -m", shell=True) - .decode() - .split("\n")[1] - .split()[1] - ) - mem_info_gb = round(int(mem_info) / 1024, 2) - info_dict["Memory Info"] = f"{mem_info_gb} GB" - except Exception as e: # pylint: disable=broad-except - info_dict["Error Memory Info"] = str(e) - - else: - info_dict["Error"] = "Unsupported OS" - - # Get Python Packages - try: - installed_packages = pkg_resources.working_set - info_dict["Python Packages"] = [ - f"{i.key}=={i.version}" - for i in installed_packages # pylint: disable=not-an-iterable - ] - except Exception as e: # pylint: disable=broad-except - info_dict["Error Python Packages"] = str(e) - - return info_dict diff --git a/src/turnkeyml/common/system_info.py b/src/turnkeyml/common/system_info.py new file mode 100644 index 00000000..9f408296 --- /dev/null +++ b/src/turnkeyml/common/system_info.py @@ -0,0 +1,386 @@ +from abc import ABC +import importlib.metadata +import platform +import re +import subprocess + + +class SystemInfo(ABC): + """Abstract base class for OS-dependent system information classes""" + + def __init__(self): + pass + + def get_dict(self): + """ + Retrieves all the system information into a dictionary + + Returns: + dict: System information + """ + info_dict = { + "OS Version": self.get_os_version(), + "Python Packages": self.get_python_packages(), + } + return info_dict + + @staticmethod + def get_os_version() -> str: + """ + Retrieves the OS version. + + Returns: + str: OS Version + """ + try: + return platform.platform() + except Exception as e: # pylint: disable=broad-except + return f"ERROR - {e}" + + @staticmethod + def get_python_packages() -> list: + """ + Retrieves the Python package versions. + + Returns: + list: List of Python package versions in the form ["package-name==package-version", ...] + """ + # Get Python Packages + distributions = importlib.metadata.distributions() + return [ + f"{dist.metadata['name']}=={dist.metadata['version']}" + for dist in distributions + ] + + +class WindowsSystemInfo(SystemInfo): + """Class used to access system information in Windows""" + + def __init__(self): + super().__init__() + import wmi + + self.connection = wmi.WMI() + + def get_processor_name(self) -> str: + """ + Retrieves the name of the processor. + + Returns: + str: Name of the processor. + """ + processors = self.connection.Win32_Processor() + if processors: + return ( + f"{processors[0].Name.strip()} " + f"({processors[0].NumberOfCores} cores, " + f"{processors[0].NumberOfLogicalProcessors} logical processors)" + ) + return "Processor information not found." + + def get_system_model(self) -> str: + """ + Retrieves the model of the computer system. + + Returns: + str: Model of the computer system. + """ + systems = self.connection.Win32_ComputerSystem() + if systems: + return systems[0].Model + return "System model information not found." + + def get_physical_memory(self) -> str: + """ + Retrieves the physical memory of the computer system. + + Returns: + str: Physical memory + """ + memory = self.connection.Win32_PhysicalMemory() + if memory: + total_capacity = sum([int(m.Capacity) for m in memory]) + total_capacity_str = f"{total_capacity/(1024**3)} GB" + details_str = " + ".join( + [ + f"{m.Manufacturer} {int(m.Capacity)/(1024**3)} GB {m.Speed} ns" + for m in memory + ] + ) + return total_capacity_str + " (" + details_str + ")" + return "Physical memory information not found." + + def get_bios_version(self) -> str: + """ + Retrieves the BIOS Version of the computer system. + + Returns: + str: BIOS Version + """ + bios = self.connection.Win32_BIOS() + if bios: + return bios[0].Name + return "BIOS Version not found." + + def get_max_clock_speed(self) -> str: + """ + Retrieves the max clock speed of the CPU of the system. + + Returns: + str: Max CPU clock speed + """ + processor = self.connection.Win32_Processor() + if processor: + return f"{processor[0].MaxClockSpeed} MHz" + return "Max CPU clock speed not found." + + def get_driver_version(self, device_name) -> str: + """ + Retrieves the driver version for the specified device name. + + Returns: + str: Driver version, or None if device driver not found + """ + drivers = self.connection.Win32_PnPSignedDriver(DeviceName=device_name) + if drivers: + return drivers[0].DriverVersion + return "" + + @staticmethod + def get_npu_power_mode() -> str: + """ + Retrieves the NPU power mode. + + Returns: + str: NPU power mode + """ + try: + out = subprocess.check_output( + [ + r"C:\Windows\System32\AMD\xrt-smi.exe", + "examine", + "-r", + "platform", + ], + stderr=subprocess.STDOUT, + ).decode() + lines = out.splitlines() + modes = [line.split()[-1] for line in lines if "Mode" in line] + if len(modes) > 0: + return modes[0] + except FileNotFoundError: + # xrt-smi not present + pass + except subprocess.CalledProcessError: + pass + return "NPU power mode not found." + + @staticmethod + def get_windows_power_setting() -> str: + """ + Retrieves the Windows power setting. + + Returns: + str: Windows power setting. + """ + try: + out = subprocess.check_output(["powercfg", "/getactivescheme"]).decode() + return re.search(r"\((.*?)\)", out).group(1) + except subprocess.CalledProcessError: + pass + return "Windows power setting not found" + + def get_dict(self) -> dict: + """ + Retrieves all the system information into a dictionary + + Returns: + dict: System information + """ + info_dict = super().get_dict() + info_dict["Processor"] = self.get_processor_name() + info_dict["OEM System"] = self.get_system_model() + info_dict["Physical Memory"] = self.get_physical_memory() + info_dict["BIOS Version"] = self.get_bios_version() + info_dict["CPU Max Clock"] = self.get_max_clock_speed() + info_dict["Windows Power Setting"] = self.get_windows_power_setting() + if "AMD" in info_dict["Processor"]: + device_names = [ + "NPU Compute Accelerator Device", + "AMD-OpenCL User Mode Driver", + ] + driver_versions = { + device_name: self.get_driver_version(device_name) + for device_name in device_names + } + info_dict["Driver Versions"] = { + k: (v if len(v) else "DEVICE NOT FOUND") + for k, v in driver_versions.items() + } + info_dict["NPU Power Mode"] = self.get_npu_power_mode() + return info_dict + + +class WSLSystemInfo(SystemInfo): + """Class used to access system information in WSL""" + + @staticmethod + def get_system_model() -> str: + """ + Retrieves the model of the computer system. + + Returns: + str: Model of the computer system. + """ + try: + oem_info = ( + subprocess.check_output( + 'powershell.exe -Command "wmic computersystem get model"', + shell=True, + ) + .decode() + .strip() + ) + oem_info = ( + oem_info.replace("\r", "").replace("\n", "").split("Model")[-1].strip() + ) + return oem_info + except Exception as e: # pylint: disable=broad-except + return f"ERROR - {e}" + + def get_dict(self) -> dict: + """ + Retrieves all the system information into a dictionary + + Returns: + dict: System information + """ + info_dict = super().get_dict() + info_dict["OEM System"] = self.get_system_model() + return info_dict + + +class LinuxSystemInfo(SystemInfo): + """Class used to access system information in Linux""" + + @staticmethod + def get_processor_name() -> str: + """ + Retrieves the name of the processor. + + Returns: + str: Name of the processor. + """ + # Get CPU Information + try: + cpu_info = subprocess.check_output("lscpu", shell=True).decode() + for line in cpu_info.split("\n"): + if "Model name:" in line: + return line.split(":")[1].strip() + except Exception as e: # pylint: disable=broad-except + return f"ERROR - {e}" + + @staticmethod + def get_system_model() -> str: + """ + Retrieves the model of the computer system. + + Returns: + str: Model of the computer system. + """ + # Get OEM System Information + try: + oem_info = ( + subprocess.check_output( + "sudo -n dmidecode -s system-product-name", + shell=True, + stderr=subprocess.DEVNULL, + ) + .decode() + .strip() + .replace("\n", " ") + ) + return oem_info + except subprocess.CalledProcessError: + # This catches the case where sudo requires a password + return "Unable to get oem info - password required" + except Exception as e: # pylint: disable=broad-except + return f"ERROR - {e}" + + @staticmethod + def get_physical_memory() -> str: + """ + Retrieves the physical memory of the computer system. + + Returns: + str: Physical memory + """ + try: + mem_info = ( + subprocess.check_output("free -m", shell=True) + .decode() + .split("\n")[1] + .split()[1] + ) + mem_info_gb = round(int(mem_info) / 1024, 2) + return f"{mem_info_gb} GB" + except Exception as e: # pylint: disable=broad-except + return f"ERROR - {e}" + + def get_dict(self) -> dict: + """ + Retrieves all the system information into a dictionary + + Returns: + dict: System information + """ + info_dict = super().get_dict() + info_dict["Processor"] = self.get_processor_name() + info_dict["OEM System"] = self.get_system_model() + info_dict["Physical Memory"] = self.get_physical_memory() + return info_dict + + +class UnsupportedOSSystemInfo(SystemInfo): + """Class used to access system information in unsupported operating systems""" + + def get_dict(self): + """ + Retrieves all the system information into a dictionary + + Returns: + dict: System information + """ + info_dict = super().get_dict() + info_dict["Error"] = "UNSUPPORTED OS" + return info_dict + + +def get_system_info() -> SystemInfo: + """ + Creates the appropriate SystemInfo object based on the operating system. + + Returns: + A subclass of SystemInfo for the current operating system. + """ + os_type = platform.system() + if os_type == "Windows": + return WindowsSystemInfo() + elif os_type == "Linux": + # WSL has to be handled differently compared to native Linux + if "microsoft" in str(platform.release()): + return WSLSystemInfo() + else: + return LinuxSystemInfo() + else: + return UnsupportedOSSystemInfo() + + +def get_system_info_dict() -> dict: + """ + Puts the system information into a dictionary. + + Returns: + dict: Dictionary containing the system information. + """ + return get_system_info().get_dict() diff --git a/src/turnkeyml/llm/cli.py b/src/turnkeyml/llm/cli.py index 8cc73f82..e8b695fb 100644 --- a/src/turnkeyml/llm/cli.py +++ b/src/turnkeyml/llm/cli.py @@ -3,7 +3,7 @@ import turnkeyml.common.filesystem as fs import turnkeyml.cli.cli as cli from turnkeyml.sequence import Sequence -from turnkeyml.tools.management_tools import Cache, Version +from turnkeyml.tools.management_tools import Cache, Version, SystemInfo from turnkeyml.tools.report import Report from turnkeyml.state import State @@ -40,6 +40,7 @@ def main(): Report, Cache, Version, + SystemInfo, ] # Import onnxruntime-genai recipes diff --git a/src/turnkeyml/sequence/sequence.py b/src/turnkeyml/sequence/sequence.py index 483ad988..dbf9a97b 100644 --- a/src/turnkeyml/sequence/sequence.py +++ b/src/turnkeyml/sequence/sequence.py @@ -7,6 +7,7 @@ import turnkeyml.common.printing as printing import turnkeyml.common.exceptions as exp import turnkeyml.common.build as build +from turnkeyml.common.system_info import get_system_info_dict import turnkeyml.common.filesystem as fs import turnkeyml.common.status as status from turnkeyml.tools.tool import Tool @@ -133,7 +134,7 @@ def launch( ) # Save the system information used for this build - system_info = build.get_system_info() + system_info = get_system_info_dict() state.save_stat( fs.Keys.SYSTEM_INFO, system_info, diff --git a/src/turnkeyml/sequence/tool_plugins.py b/src/turnkeyml/sequence/tool_plugins.py index 0553e60f..cb0f9388 100644 --- a/src/turnkeyml/sequence/tool_plugins.py +++ b/src/turnkeyml/sequence/tool_plugins.py @@ -17,6 +17,7 @@ def get_supported_tools(): mgmt.Version, mgmt.Cache, mgmt.ModelsLocation, + mgmt.SystemInfo, report.Report, Discover, export.ExportPytorchModel, diff --git a/src/turnkeyml/tools/management_tools.py b/src/turnkeyml/tools/management_tools.py index 5a0d2905..c92dd52e 100644 --- a/src/turnkeyml/tools/management_tools.py +++ b/src/turnkeyml/tools/management_tools.py @@ -7,6 +7,7 @@ import turnkeyml.common.printing as printing from turnkeyml.tools.tool import ToolParser from turnkeyml.version import __version__ as turnkey_version +from turnkeyml.common.system_info import get_system_info_dict class ManagementTool(abc.ABC): @@ -265,3 +266,37 @@ def run(self, _, quiet: bool = False): print(fs.MODELS_DIR) else: printing.log_info(f"The models directory is: {fs.MODELS_DIR}") + + +class SystemInfo(ManagementTool): + """ + Prints system information for the turnkeyml installation. + """ + + unique_name = "system-info" + + @staticmethod + def parser(add_help: bool = True) -> argparse.ArgumentParser: + parser = __class__.helpful_parser( + short_description="Print system information", + add_help=add_help, + ) + + return parser + + @staticmethod + def pretty_print(my_dict: dict, level=0): + for k, v in my_dict.items(): + if isinstance(v, dict): + print(" " * level + f"{k}:") + SystemInfo.pretty_print(v, level + 1) + elif isinstance(v, list): + print(" " * level + f"{k}:") + for item in v: + print(" " * (level + 1) + f"{item}") + else: + print(" " * level + f"{k}: {v}") + + def run(self, _): + system_info_dict = get_system_info_dict() + self.pretty_print(system_info_dict)