diff --git a/runpod/serverless/utils/rp_debugger.py b/runpod/serverless/utils/rp_debugger.py index 6b37f196..7485006a 100644 --- a/runpod/serverless/utils/rp_debugger.py +++ b/runpod/serverless/utils/rp_debugger.py @@ -6,24 +6,43 @@ import datetime from datetime import timezone +import functools import platform import time -import cpuinfo - # ---------------------------------------------------------------------------- # # System Info # # ---------------------------------------------------------------------------- # OS_INFO = f"{platform.system()} {platform.release()}" -try: - PROCESSOR = cpuinfo.get_cpu_info()["brand_raw"] -except KeyError: - PROCESSOR = "Unable to get processor info." - PYTHON_VERSION = platform.python_version() +@functools.lru_cache(maxsize=1) +def _get_processor() -> str: + """ + Return the CPU brand string, probed on first use. + + py-cpuinfo is slow on x86: it measures clock speed in a child process + around a fixed sleep(1). The worker imports this module on every start, + so probing here instead of at import keeps that second off cold starts + when the debugger is not enabled. + """ + import cpuinfo # pylint: disable=import-outside-toplevel + + try: + return cpuinfo.get_cpu_info()["brand_raw"] + except KeyError: + return "Unable to get processor info." + + +def __getattr__(name: str): + """Resolve PROCESSOR lazily for callers that read the module attribute.""" + if name == "PROCESSOR": + return _get_processor() + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + class Checkpoints: """ A singleton class to store checkpoint times. @@ -192,7 +211,7 @@ def get_debugger_output(): system_info = { "os": OS_INFO, - "processor": PROCESSOR, + "processor": _get_processor(), "python_version": PYTHON_VERSION, "runpod": runpod.__version__, } diff --git a/tests/test_serverless/test_utils/test_debugger.py b/tests/test_serverless/test_utils/test_debugger.py index ca68c86d..0fb03bce 100644 --- a/tests/test_serverless/test_utils/test_debugger.py +++ b/tests/test_serverless/test_utils/test_debugger.py @@ -3,6 +3,8 @@ """ import importlib +import subprocess +import sys import time import unittest from unittest.mock import patch @@ -23,18 +25,82 @@ class TestDebugger(unittest.TestCase): def setUp(self): self.checkpoints = Checkpoints() self.checkpoints.clear() + rp_debugger._get_processor.cache_clear() - @patch("runpod.serverless.utils.rp_debugger.cpuinfo.get_cpu_info") + def tearDown(self): + rp_debugger._get_processor.cache_clear() + + @patch("cpuinfo.get_cpu_info") + def test_import_does_not_probe_cpu(self, mock_get_cpu_info): + """ + Importing the module must not run py-cpuinfo; on x86 it sleeps 1s. + """ + importlib.reload(rp_debugger) + + mock_get_cpu_info.assert_not_called() + + def test_import_does_not_load_cpuinfo(self): + """ + Importing the module must not import py-cpuinfo, which spawns a subprocess at import. + """ + result = subprocess.run( + [ + sys.executable, + "-c", + "import sys; import runpod.serverless.utils.rp_debugger; " + "print('cpuinfo' in sys.modules)", + ], + capture_output=True, + text=True, + check=True, + ) + + self.assertEqual(result.stdout.strip(), "False") + + @patch("cpuinfo.get_cpu_info") + def test_debugger_output_reports_processor(self, mock_get_cpu_info): + """ + The processor is looked up when debugger output is built. + """ + mock_get_cpu_info.return_value = {"brand_raw": "Test CPU"} + + output = get_debugger_output() + + self.assertEqual(output["system_info"]["processor"], "Test CPU") + + @patch("cpuinfo.get_cpu_info") + def test_processor_lookup_is_cached(self, mock_get_cpu_info): + """ + The processor is probed once per process, not once per job. + """ + mock_get_cpu_info.return_value = {"brand_raw": "Test CPU"} + + get_debugger_output() + get_debugger_output() + + mock_get_cpu_info.assert_called_once() + + @patch("cpuinfo.get_cpu_info") def test_key_error(self, mock_get_cpu_info): """ - Test that a KeyError is raised when an invalid key is used. + A missing brand string falls back to a placeholder. """ mock_get_cpu_info.side_effect = KeyError("Test Error") - importlib.reload(rp_debugger) + output = get_debugger_output() + + self.assertEqual( + output["system_info"]["processor"], "Unable to get processor info." + ) + + @patch("cpuinfo.get_cpu_info") + def test_processor_attribute_is_lazy(self, mock_get_cpu_info): + """ + The PROCESSOR module attribute still resolves for existing callers. + """ + mock_get_cpu_info.return_value = {"brand_raw": "Test CPU"} - assert mock_get_cpu_info.called - self.assertEqual(rp_debugger.PROCESSOR, "Unable to get processor info.") + self.assertEqual(rp_debugger.PROCESSOR, "Test CPU") def test_checkpoints(self): """