Skip to content
Closed
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
54 changes: 54 additions & 0 deletions src/whichllm/hardware/apple.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@

from __future__ import annotations

import ctypes
import ctypes.util
import json
import logging
import re
import subprocess
import sys
from pathlib import Path

from whichllm.constants import GPU_BANDWIDTH
Expand Down Expand Up @@ -46,6 +49,49 @@ def _detect_iogpu_wired_limit_bytes() -> int | None:
return limit_mb * _MiB if limit_mb > 0 else None


def _detect_metal_recommended_working_set_bytes() -> int | None:
"""Return Metal's ``recommendedMaxWorkingSetSize`` for the default device.

This is the GPU budget macOS actually enforces on Apple Silicon. It is well
below total physical memory (0.74x on a stock 16 GB machine), and it is not
exposed through ``sysctl`` or ``system_profiler`` — the Metal API is the
only way to read it. Uses ``ctypes`` against the system Metal and objc
libraries, so it adds no dependency.

Returns ``None`` on non-macOS hosts or if the frameworks cannot be reached.
"""
if sys.platform != "darwin":
return None

try:
metal_path = ctypes.util.find_library("Metal")
objc_path = ctypes.util.find_library("objc")
if not metal_path or not objc_path:
return None

metal = ctypes.CDLL(metal_path)
objc = ctypes.CDLL(objc_path)

metal.MTLCreateSystemDefaultDevice.restype = ctypes.c_void_p
device = metal.MTLCreateSystemDefaultDevice()
if not device:
return None

objc.sel_registerName.restype = ctypes.c_void_p
objc.sel_registerName.argtypes = [ctypes.c_char_p]
selector = objc.sel_registerName(b"recommendedMaxWorkingSetSize")

send = objc.objc_msgSend
send.restype = ctypes.c_uint64
send.argtypes = [ctypes.c_void_p, ctypes.c_void_p]
working_set = send(device, selector)
except (OSError, AttributeError, ValueError) as e:
logger.debug(f"Metal working set size unavailable: {e}")
return None

return working_set if working_set > 0 else None


def detect_apple_gpu() -> list[GPUInfo]:
"""Detect Apple Silicon GPU. Returns empty list on non-macOS or failure."""
try:
Expand Down Expand Up @@ -81,7 +127,15 @@ def detect_apple_gpu() -> list[GPUInfo]:
unified_memory = mem_value * multiplier
wired_limit = _detect_iogpu_wired_limit_bytes()
if wired_limit is not None:
# The user raised or set iogpu.wired_limit_mb explicitly: honour it.
unified_memory = min(unified_memory, wired_limit)
else:
# Stock machine (iogpu.wired_limit_mb == 0). The GPU still cannot
# address all of physical memory, so fall back to the budget Metal
# reports rather than assuming 100% of RAM is usable.
metal_working_set = _detect_metal_recommended_working_set_bytes()
if metal_working_set is not None:
unified_memory = min(unified_memory, metal_working_set)

return [
GPUInfo(
Expand Down
87 changes: 86 additions & 1 deletion tests/test_apple_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def fake_run(args, **kwargs):
assert gpus[0].shared_memory is True


def test_detect_apple_gpu_keeps_unified_memory_when_wired_limit_is_unavailable(
def test_detect_apple_gpu_keeps_unified_memory_when_no_budget_signal(
monkeypatch,
):
hardware = {
Expand All @@ -58,6 +58,9 @@ def fake_run(args, **kwargs):
raise AssertionError(f"Unexpected command: {args}")

monkeypatch.setattr(apple.subprocess, "run", fake_run)
monkeypatch.setattr(
apple, "_detect_metal_recommended_working_set_bytes", lambda: None
)

gpus = apple.detect_apple_gpu()

Expand All @@ -66,6 +69,9 @@ def fake_run(args, **kwargs):


def test_detect_apple_gpu_keeps_unified_memory_when_sysctl_cannot_run(monkeypatch):
monkeypatch.setattr(
apple, "_detect_metal_recommended_working_set_bytes", lambda: None
)
hardware = {
"SPHardwareDataType": [
{
Expand All @@ -90,3 +96,82 @@ def fake_run(args, **kwargs):

assert len(gpus) == 1
assert gpus[0].vram_bytes == 32 * 1024**3


def _hardware_payload(chip: str = "Apple M4", memory: str = "16 GB") -> dict:
return {"SPHardwareDataType": [{"chip_type": chip, "physical_memory": memory}]}


def _fake_run_with_wired_limit(payload: dict, sysctl_stdout: str):
def fake_run(args, **kwargs):
if args == ["system_profiler", "SPHardwareDataType", "-json"]:
return subprocess.CompletedProcess(
args, 0, stdout=json.dumps(payload), stderr=""
)
if args == ["sysctl", "-n", "iogpu.wired_limit_mb"]:
return subprocess.CompletedProcess(args, 0, stdout=sysctl_stdout, stderr="")
raise AssertionError(f"Unexpected command: {args}")

return fake_run


def test_detect_apple_gpu_falls_back_to_metal_working_set_on_stock_machine(monkeypatch):
"""iogpu.wired_limit_mb == 0 is the default on every unmodified Mac.

Before this, the whole of physical memory was reported as GPU-addressable.
Measured on a 16 GB M4: Metal reports 11.84 GiB, llama.cpp agrees
(12124.17 MiB), so the old behaviour overstated the budget by 20%.
"""
monkeypatch.setattr(
apple.subprocess, "run", _fake_run_with_wired_limit(_hardware_payload(), "0\n")
)
monkeypatch.setattr(
apple,
"_detect_metal_recommended_working_set_bytes",
lambda: 12713115648, # 11.84 GiB, read from Metal on a 16 GB M4
)

gpus = apple.detect_apple_gpu()

assert len(gpus) == 1
assert gpus[0].vram_bytes == 12713115648


def test_detect_apple_gpu_prefers_explicit_wired_limit_over_metal(monkeypatch):
"""An explicitly raised wired limit is a deliberate user choice: honour it."""
monkeypatch.setattr(
apple.subprocess,
"run",
_fake_run_with_wired_limit(
_hardware_payload("Apple M1 Max", "32 GB"), "26000\n"
),
)
monkeypatch.setattr(
apple,
"_detect_metal_recommended_working_set_bytes",
lambda: 24 * 1024**3,
)

gpus = apple.detect_apple_gpu()

assert gpus[0].vram_bytes == 26000 * 1024**2


def test_detect_apple_gpu_never_exceeds_physical_memory(monkeypatch):
"""A Metal value above installed RAM must not inflate the budget."""
monkeypatch.setattr(
apple.subprocess, "run", _fake_run_with_wired_limit(_hardware_payload(), "0\n")
)
monkeypatch.setattr(
apple, "_detect_metal_recommended_working_set_bytes", lambda: 64 * 1024**3
)

gpus = apple.detect_apple_gpu()

assert gpus[0].vram_bytes == 16 * 1024**3


def test_metal_working_set_probe_returns_none_off_darwin(monkeypatch):
monkeypatch.setattr(apple.sys, "platform", "linux")

assert apple._detect_metal_recommended_working_set_bytes() is None