Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -216,4 +216,5 @@ __marimo__/
.streamlit/secrets.toml
internal/

# macOS
.DS_Store
28 changes: 28 additions & 0 deletions tests/test_hardware_context.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"
107 changes: 106 additions & 1 deletion tests/test_platform_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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(),
Expand Down
11 changes: 10 additions & 1 deletion tests/test_platform_macos.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import pytest

from wildedge.platforms.hardware import ThermalContext
from wildedge.platforms.macos import MacOSPlatform


Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion wildedge/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,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,
Expand Down
65 changes: 60 additions & 5 deletions wildedge/platforms/macos.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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")
Expand All @@ -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
Expand Down
Loading