From 793d72db2b7f59217eaa2a9fa33992ed5edf6266 Mon Sep 17 00:00:00 2001 From: Piotr Duda Date: Thu, 30 Apr 2026 23:03:38 +0200 Subject: [PATCH] fix accelerator_actual wiring and add macOS cpu_freq/thermal sampling --- .gitignore | 3 + tests/test_hardware_context.py | 28 +++++++++ tests/test_platform_adapters.py | 107 +++++++++++++++++++++++++++++++- tests/test_platform_macos.py | 11 +++- uv.lock | 2 +- wildedge/model.py | 2 +- wildedge/platforms/macos.py | 65 +++++++++++++++++-- 7 files changed, 209 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index 468c518..4dcd2c5 100644 --- a/.gitignore +++ b/.gitignore @@ -215,3 +215,6 @@ __marimo__/ # Streamlit .streamlit/secrets.toml internal/ + +# macOS +.DS_Store diff --git a/tests/test_hardware_context.py b/tests/test_hardware_context.py index d35f0a8..0240818 100644 --- a/tests/test_hardware_context.py +++ b/tests/test_hardware_context.py @@ -1,6 +1,7 @@ from __future__ import annotations from wildedge.events.inference import InferenceEvent +from wildedge.model import ModelHandle, ModelInfo from wildedge.platforms import capture_hardware from wildedge.platforms.hardware import HardwareContext, ThermalContext from wildedge.platforms.linux import LinuxPlatform @@ -81,3 +82,30 @@ def test_capture_preserves_existing_accelerator_when_not_passed(monkeypatch): lambda self: HardwareContext(accelerator_actual="mps"), ) assert capture_hardware().accelerator_actual == "mps" + + +def test_track_inference_passes_detected_accelerator_to_hardware(monkeypatch): + """detected_accelerator on the handle must appear as accelerator_actual in the event.""" + monkeypatch.setattr("wildedge.model.is_sampling", lambda: True) + monkeypatch.setattr( + "wildedge.model.capture_hardware", + lambda accelerator_actual=None: HardwareContext( + accelerator_actual=accelerator_actual + ), + ) + + published = [] + info = ModelInfo( + model_name="test", + model_format="pytorch", + model_version="1", + model_source="local", + ) + handle = ModelHandle(model_id="m1", info=info, publish=published.append) + handle.detected_accelerator = "cuda" + + handle.track_inference(duration_ms=50) + + assert len(published) == 1 + hardware = published[0]["inference"].get("hardware", {}) + assert hardware.get("accelerator_actual") == "cuda" diff --git a/tests/test_platform_adapters.py b/tests/test_platform_adapters.py index 168e98d..05a1de4 100644 --- a/tests/test_platform_adapters.py +++ b/tests/test_platform_adapters.py @@ -2,11 +2,29 @@ from wildedge.platforms.base import cuda_device_count from wildedge.platforms.linux import LinuxPlatform -from wildedge.platforms.macos import MacOSPlatform +from wildedge.platforms.macos import MACOS_THERMAL_STATES, MacOSPlatform from wildedge.platforms.unknown import UnknownPlatform from wildedge.platforms.windows import WindowsPlatform +class FakeObjc: + """Minimal stand-in for the libobjc CDLL used in MacOSPlatform.thermal(). + + Absorbs restype/argtypes assignments silently and returns a no-op callable + for any attribute not explicitly defined. + """ + + objc_getClass = staticmethod(lambda name: 1) # noqa: N815 + sel_registerName = staticmethod(lambda name: 2) # noqa: N815 + objc_msgSend = staticmethod(lambda *a: 3) # noqa: N815 + + def __getattr__(self, name): + return lambda *a, **kw: 0 + + def __setattr__(self, name, val): + pass + + def test_linux_gpu_accelerators_cuda_and_rocm_counts(monkeypatch): platform = LinuxPlatform() monkeypatch.setattr("wildedge.platforms.linux.cuda_device_count", lambda _: 1) @@ -116,6 +134,93 @@ def test_unknown_os_version_returns_platform_version(monkeypatch): assert UnknownPlatform().os_version() == "some-kernel-version" +def test_macos_thermal_states_covers_all_levels(): + assert set(MACOS_THERMAL_STATES) == {0, 1, 2, 3} + states = {v[0] for v in MACOS_THERMAL_STATES.values()} + assert states == {"nominal", "fair", "serious", "critical"} + + +def test_macos_cpu_freq_intel(monkeypatch): + """On Intel, both hw.cpufrequency and hw.cpufrequency_max are available.""" + import wildedge.platforms.macos as macos_mod + + calls = [] + + def fake_sysctl(name): + calls.append(name) + return { + b"hw.cpufrequency": 2_400_000_000, + b"hw.cpufrequency_max": 3_200_000_000, + }.get(name) + + monkeypatch.setattr(macos_mod, "_sysctl_uint64", fake_sysctl) + cur, max_f = MacOSPlatform().cpu_freq() + assert cur == 2400 + assert max_f == 3200 + + +def test_macos_cpu_freq_apple_silicon(monkeypatch): + """On Apple Silicon, hw.cpufrequency is absent; max falls back to hw.perflevel0.cpufrequency_max.""" + import wildedge.platforms.macos as macos_mod + + def fake_sysctl(name): + return {b"hw.perflevel0.cpufrequency_max": 4_056_000_000}.get(name) + + monkeypatch.setattr(macos_mod, "_sysctl_uint64", fake_sysctl) + cur, max_f = MacOSPlatform().cpu_freq() + assert cur is None + assert max_f == 4056 + + +def test_macos_thermal_nominal(monkeypatch): + import wildedge.platforms.macos as macos_mod + + monkeypatch.setattr(macos_mod.ctypes.cdll, "LoadLibrary", lambda _: FakeObjc()) + monkeypatch.setattr( + macos_mod.ctypes, + "CFUNCTYPE", + lambda *types: lambda fn: lambda *a: 0, # always returns level 0 (nominal) + ) + monkeypatch.setattr( + macos_mod.ctypes, "cast", lambda obj, t: type("V", (), {"value": 0})() + ) + + ctx = MacOSPlatform().thermal() + assert ctx is not None + assert ctx.state == "nominal" + + +def test_macos_thermal_serious(monkeypatch): + import wildedge.platforms.macos as macos_mod + + monkeypatch.setattr(macos_mod.ctypes.cdll, "LoadLibrary", lambda _: FakeObjc()) + monkeypatch.setattr( + macos_mod.ctypes, + "CFUNCTYPE", + lambda *types: lambda fn: lambda *a: 2, # level 2 = serious + ) + monkeypatch.setattr( + macos_mod.ctypes, "cast", lambda obj, t: type("V", (), {"value": 0})() + ) + + ctx = MacOSPlatform().thermal() + assert ctx is not None + assert ctx.state == "serious" + assert ctx.state_raw == "serious" + + +def test_macos_thermal_returns_none_on_error(monkeypatch): + import wildedge.platforms.macos as macos_mod + + monkeypatch.setattr( + macos_mod.ctypes.cdll, + "LoadLibrary", + lambda _: (_ for _ in ()).throw(OSError("no objc")), + ) + + assert MacOSPlatform().thermal() is None + + def test_platform_adapters_expose_state_and_cache_paths(): for adapter in ( LinuxPlatform(), diff --git a/tests/test_platform_macos.py b/tests/test_platform_macos.py index 9506321..096030c 100644 --- a/tests/test_platform_macos.py +++ b/tests/test_platform_macos.py @@ -2,6 +2,7 @@ import pytest +from wildedge.platforms.hardware import ThermalContext from wildedge.platforms.macos import MacOSPlatform @@ -42,12 +43,20 @@ def test_ram_bytes_delegates_to_meminfo(monkeypatch): def test_hardware_context_fields(monkeypatch): monkeypatch.setattr(MacOSPlatform, "meminfo", lambda self: (None, 3_000_000_000)) monkeypatch.setattr(MacOSPlatform, "battery", lambda self: (0.8, False)) + monkeypatch.setattr(MacOSPlatform, "cpu_freq", lambda self: (None, 4056)) + monkeypatch.setattr( + MacOSPlatform, + "thermal", + lambda self: ThermalContext(state="fair", state_raw="fair"), + ) ctx = MacOSPlatform().hardware_context() assert ctx.memory_available_bytes == 3_000_000_000 assert ctx.battery_level == pytest.approx(0.8) assert ctx.battery_charging is False assert ctx.cpu_freq_mhz is None - assert ctx.thermal is None + assert ctx.cpu_freq_max_mhz == 4056 + assert ctx.thermal is not None + assert ctx.thermal.state == "fair" @pytest.mark.requires_macos diff --git a/uv.lock b/uv.lock index 58a4ccf..0e2363c 100644 --- a/uv.lock +++ b/uv.lock @@ -419,7 +419,7 @@ wheels = [ [[package]] name = "wildedge-sdk" -version = "0.1.3" +version = "0.1.4" source = { editable = "." } [package.dev-dependencies] diff --git a/wildedge/model.py b/wildedge/model.py index 2502b14..24f351e 100644 --- a/wildedge/model.py +++ b/wildedge/model.py @@ -214,7 +214,7 @@ def track_inference( context: dict[str, Any] | None = None, ) -> str: if hardware is None and is_sampling(): - hardware = capture_hardware() + hardware = capture_hardware(accelerator_actual=self.detected_accelerator) correlation = _merge_correlation_fields( trace_id=trace_id, span_id=span_id, diff --git a/wildedge/platforms/macos.py b/wildedge/platforms/macos.py index bcbb71b..dc86852 100644 --- a/wildedge/platforms/macos.py +++ b/wildedge/platforms/macos.py @@ -6,6 +6,7 @@ from pathlib import Path from wildedge.platforms.base import Platform, debug_detection_failure +from wildedge.platforms.hardware import ThermalContext _libc: ctypes.CDLL | None = None @@ -17,8 +18,16 @@ def _get_libc() -> ctypes.CDLL: return _libc -_CF_STRING_ENCODING_UTF8 = 0x08000100 -_CF_NUMBER_SINT32_TYPE = 3 +CF_STRING_ENCODING_UTF8 = 0x08000100 +CF_NUMBER_SINT32_TYPE = 3 + +# NSProcessInfoThermalState values (0–3) +MACOS_THERMAL_STATES: dict[int, tuple[str, str]] = { + 0: ("nominal", "nominal"), + 1: ("fair", "fair"), + 2: ("serious", "serious"), + 3: ("critical", "critical"), +} def _sysctl_uint32(name: bytes) -> int | None: @@ -108,6 +117,52 @@ def gpu_accelerators(self) -> tuple[list[str], str | None]: def gpu_accelerator_for_offload(self) -> str: return "mps" if platform.machine() == "arm64" else "cpu" + def cpu_freq(self) -> tuple[int | None, int | None]: + """Read CPU frequency via sysctl. + + Current freq (hw.cpufrequency) is Intel-only — Apple Silicon does not expose + per-core clock speed through public sysctl, so cur_mhz is None on M-series. + + Max freq falls back to hw.perflevel0.cpufrequency_max (P-core cluster) when + hw.cpufrequency_max is absent, so max_mhz is populated on both architectures. + """ + cur = _sysctl_uint64(b"hw.cpufrequency") + cur_mhz = (cur // 1_000_000) if cur else None + + max_f = _sysctl_uint64(b"hw.cpufrequency_max") or _sysctl_uint64( + b"hw.perflevel0.cpufrequency_max" + ) + max_mhz = (max_f // 1_000_000) if max_f else None + + return cur_mhz, max_mhz + + def thermal(self) -> ThermalContext | None: + """Read thermal pressure via NSProcessInfo.thermalState (Objective-C runtime).""" + try: + objc = ctypes.cdll.LoadLibrary("/usr/lib/libobjc.A.dylib") + objc.objc_getClass.restype = ctypes.c_void_p + objc.sel_registerName.restype = ctypes.c_void_p + objc.objc_msgSend.restype = ctypes.c_void_p + objc.objc_msgSend.argtypes = [ctypes.c_void_p, ctypes.c_void_p] + + ns_process_info_cls = objc.objc_getClass(b"NSProcessInfo") + info = objc.objc_msgSend( + ns_process_info_cls, objc.sel_registerName(b"processInfo") + ) + + # thermalState returns NSInteger — cast objc_msgSend to the right signature + addr = ctypes.cast(objc.objc_msgSend, ctypes.c_void_p).value + msg_int = ctypes.CFUNCTYPE(ctypes.c_long, ctypes.c_void_p, ctypes.c_void_p)( + addr + ) + level = int(msg_int(info, objc.sel_registerName(b"thermalState"))) + + state, state_raw = MACOS_THERMAL_STATES.get(level, ("nominal", "nominal")) + return ThermalContext(state=state, state_raw=state_raw) + except Exception as exc: + debug_detection_failure("macos thermal", exc) + return None + def battery(self) -> tuple[float | None, bool | None]: """Read battery level and charging state via IOKit AppleSmartBattery.""" try: @@ -163,7 +218,7 @@ def battery(self) -> tuple[float | None, bool | None]: def cfstr(s: bytes) -> ctypes.c_void_p: return ctypes.c_void_p( - cf.CFStringCreateWithCString(None, s, _CF_STRING_ENCODING_UTF8) + cf.CFStringCreateWithCString(None, s, CF_STRING_ENCODING_UTF8) ) cap_key = cfstr(b"CurrentCapacity") @@ -179,10 +234,10 @@ def cfstr(s: bytes) -> ctypes.c_void_p: cur = ctypes.c_int32(0) mx = ctypes.c_int32(0) cf.CFNumberGetValue( - ctypes.c_void_p(cap_ref), _CF_NUMBER_SINT32_TYPE, ctypes.byref(cur) + ctypes.c_void_p(cap_ref), CF_NUMBER_SINT32_TYPE, ctypes.byref(cur) ) cf.CFNumberGetValue( - ctypes.c_void_p(max_ref), _CF_NUMBER_SINT32_TYPE, ctypes.byref(mx) + ctypes.c_void_p(max_ref), CF_NUMBER_SINT32_TYPE, ctypes.byref(mx) ) if mx.value > 0: level = cur.value / mx.value