From 366937de44ded599b569f9a90521ee9da32dcdee Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sun, 31 May 2026 00:59:25 -0700 Subject: [PATCH] studio: pick a macOS llama.cpp prebuilt that loads on the host OS (#5883) Make macOS llama.cpp prebuilt selection host-OS-version aware: skip a prebuilt whose minimum-OS exceeds the host and walk back to the newest release that loads (macOS 26 keeps latest; 14/15 land on a compatible older release). Source-build fallback pins CMAKE_OSX_DEPLOYMENT_TARGET=13.3. CI: binary-load assertion plus a macos-14/15/26 install matrix. No change to Linux/Windows or CUDA selection. --- .github/scripts/assert-llama-loads.sh | 57 ++++ .github/workflows/studio-mac-api-smoke.yml | 9 +- .../workflows/studio-mac-inference-smoke.yml | 27 +- .../workflows/studio-mac-install-matrix.yml | 80 +++++ .github/workflows/studio-mac-ui-smoke.yml | 9 +- .github/workflows/studio-mac-update-smoke.yml | 17 +- studio/install_llama_prebuilt.py | 228 ++++++++++++- studio/setup.sh | 9 + .../install/test_macos_version_compat.py | 322 ++++++++++++++++++ 9 files changed, 705 insertions(+), 53 deletions(-) create mode 100755 .github/scripts/assert-llama-loads.sh create mode 100644 .github/workflows/studio-mac-install-matrix.yml create mode 100644 tests/studio/install/test_macos_version_compat.py diff --git a/.github/scripts/assert-llama-loads.sh b/.github/scripts/assert-llama-loads.sh new file mode 100755 index 0000000000..c2ffe27469 --- /dev/null +++ b/.github/scripts/assert-llama-loads.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. +# +# Assert Studio installed a llama.cpp that loads and runs on THIS macOS. Tests +# the contract that matters (binaries load and their minimum-OS is <= this host) +# instead of the old "did install.sh fall back to a source build?" grep, since a +# source build with a correct deployment target is a valid outcome. +set -uo pipefail + +UNSLOTH_HOME="${STUDIO_HOME:-$HOME/.unsloth}" +LLAMA_DIR="${LLAMA_CPP_DIR:-$UNSLOTH_HOME/llama.cpp}" +BIN_DIR="$LLAMA_DIR/build/bin" + +fail() { + echo "::error::$*" + if [ -f logs/install.log ]; then + echo "---- install.log (llama.cpp lines) ----" + grep -E "llama-prebuilt|llama\.cpp|macos prebuilt|falling back" logs/install.log | tail -80 || true + fi + exit 1 +} + +SERVER="$(find "$LLAMA_DIR" -type f -name 'llama-server' 2>/dev/null | head -1)" +QUANT="$(find "$LLAMA_DIR" -type f -name 'llama-quantize' 2>/dev/null | head -1)" +[ -n "$SERVER" ] || fail "llama-server not found under $LLAMA_DIR after install" +[ -n "$QUANT" ] || fail "llama-quantize not found under $LLAMA_DIR after install" + +HOST_VER="$(sw_vers -productVersion 2>/dev/null || echo '0')" +HOST_MAJOR="${HOST_VER%%.*}" + +# Static minimum-OS check on every Mach-O we ship. vtool ships with the Xcode +# command line tools, which GitHub macOS runners always have; if it is somehow +# missing we skip the static check and rely on the runtime launch below. +if command -v vtool >/dev/null 2>&1; then + while IFS= read -r macho; do + [ -n "$macho" ] || continue + minos="$(vtool -show-build "$macho" 2>/dev/null | awk '/minos/{print $2; exit}')" + [ -n "$minos" ] || continue + min_major="${minos%%.*}" + if [ "$min_major" -gt "$HOST_MAJOR" ] 2>/dev/null; then + fail "$(basename "$macho") is built for macOS $minos but this runner is macOS $HOST_VER (prebuilt is newer than the host)" + fi + done < <(find "$BIN_DIR" -type f \( -name '*.dylib' -o -name 'llama-server' -o -name 'llama-quantize' \) 2>/dev/null) +fi + +# Runtime launch: --version forces dyld to load every linked dylib (including +# libggml-metal.dylib). A missing Metal symbol or too-new binary fails here. +if ! "$SERVER" --version >/tmp/llama-server-version.txt 2>&1; then + echo "---- llama-server --version output ----" + cat /tmp/llama-server-version.txt || true + fail "llama-server failed to launch on macOS $HOST_VER (dyld load / symbol error)" +fi + +echo "llama.cpp load validation passed on macOS $HOST_VER" +echo " server: $SERVER" +sed -n '1,4p' /tmp/llama-server-version.txt 2>/dev/null || true diff --git a/.github/workflows/studio-mac-api-smoke.yml b/.github/workflows/studio-mac-api-smoke.yml index b4e274155e..8839b559fd 100644 --- a/.github/workflows/studio-mac-api-smoke.yml +++ b/.github/workflows/studio-mac-api-smoke.yml @@ -89,13 +89,8 @@ jobs: set -o pipefail bash install.sh --local --no-torch 2>&1 | tee logs/install.log - - name: Assert install.sh used the Mac llama.cpp prebuilt - run: | - if grep -q "falling back to source build" logs/install.log; then - echo "::error::install.sh fell back to source-build llama.cpp on Mac. Studio must install the prebuilt llama-bNNNN-bin-macos-arm64 on Apple Silicon." - grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60 - exit 1 - fi + - name: Assert llama.cpp loads on this macOS + run: bash .github/scripts/assert-llama-loads.sh - name: Install pyjwt for the JWT-expiry forge test run: pip install 'pyjwt>=2.6' diff --git a/.github/workflows/studio-mac-inference-smoke.yml b/.github/workflows/studio-mac-inference-smoke.yml index fab0a36bd1..1096b1abb4 100644 --- a/.github/workflows/studio-mac-inference-smoke.yml +++ b/.github/workflows/studio-mac-inference-smoke.yml @@ -114,13 +114,8 @@ jobs: set -o pipefail bash install.sh --local --no-torch 2>&1 | tee logs/install.log - - name: Assert install.sh used the Mac llama.cpp prebuilt - run: | - if grep -q "falling back to source build" logs/install.log; then - echo "::error::install.sh fell back to source-build llama.cpp on Mac. Studio must install the prebuilt llama-bNNNN-bin-macos-arm64 on Apple Silicon." - grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60 - exit 1 - fi + - name: Assert llama.cpp loads on this macOS + run: bash .github/scripts/assert-llama-loads.sh - name: Install OpenAI + Anthropic Python SDKs run: pip install 'openai>=1.50' 'anthropic>=0.40' @@ -369,13 +364,8 @@ jobs: set -o pipefail bash install.sh --local --no-torch 2>&1 | tee logs/install.log - - name: Assert install.sh used the Mac llama.cpp prebuilt - run: | - if grep -q "falling back to source build" logs/install.log; then - echo "::error::install.sh fell back to source-build llama.cpp on Mac. Studio must install the prebuilt llama-bNNNN-bin-macos-arm64 on Apple Silicon." - grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60 - exit 1 - fi + - name: Assert llama.cpp loads on this macOS + run: bash .github/scripts/assert-llama-loads.sh - name: Reset auth + boot Studio (API-only, default tool policy) # We deliberately use the API-only mode rather than @@ -760,13 +750,8 @@ jobs: set -o pipefail bash install.sh --local --no-torch 2>&1 | tee logs/install.log - - name: Assert install.sh used the Mac llama.cpp prebuilt - run: | - if grep -q "falling back to source build" logs/install.log; then - echo "::error::install.sh fell back to source-build llama.cpp on Mac. Studio must install the prebuilt llama-bNNNN-bin-macos-arm64 on Apple Silicon." - grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60 - exit 1 - fi + - name: Assert llama.cpp loads on this macOS + run: bash .github/scripts/assert-llama-loads.sh - name: Install OpenAI + Anthropic Python SDKs run: pip install 'openai>=1.50' 'anthropic>=0.40' diff --git a/.github/workflows/studio-mac-install-matrix.yml b/.github/workflows/studio-mac-install-matrix.yml new file mode 100644 index 0000000000..4e2722d1cd --- /dev/null +++ b/.github/workflows/studio-mac-install-matrix.yml @@ -0,0 +1,80 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. + +# Proves Studio's llama.cpp install loads on every supported macOS. The heavy +# app smokes stay single-OS; this matrix covers the OS-version dimension cheaply +# (install.sh + binary-load assert). Regression guard for the macOS-version +# selection in studio/install_llama_prebuilt.py. + +name: Mac Studio Install Matrix CI + +on: + pull_request: + paths: + - 'studio/install_llama_prebuilt.py' + - 'studio/setup.sh' + - 'install.sh' + - '.github/scripts/assert-llama-loads.sh' + - '.github/workflows/studio-mac-install-matrix.yml' + push: + branches: [main, pip] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + install-load: + name: Install + load (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 25 + continue-on-error: ${{ matrix.experimental }} + strategy: + fail-fast: false + matrix: + include: + - os: macos-14 # Apple Silicon, macOS 14 Sonoma + experimental: false + - os: macos-15 # Apple Silicon, macOS 15 Sequoia + experimental: false + - os: macos-26 # Apple Silicon, macOS 26 Tahoe + experimental: false + - os: macos-15-intel # Intel x86_64, macOS 15 (informational) + experimental: true + - os: macos-26-intel # Intel x86_64, macOS 26 (last Intel macOS) + experimental: true + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '22' + + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: '3.12' + + - name: Install Studio (--local, --no-torch) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + mkdir -p logs + set -o pipefail + bash install.sh --local --no-torch 2>&1 | tee logs/install.log + + - name: Assert llama.cpp loads on this macOS + run: bash .github/scripts/assert-llama-loads.sh + + - name: Upload install log + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: mac-install-matrix-${{ matrix.os }}-log + path: logs/install.log + retention-days: 7 diff --git a/.github/workflows/studio-mac-ui-smoke.yml b/.github/workflows/studio-mac-ui-smoke.yml index b353f0ec83..0176b0a168 100644 --- a/.github/workflows/studio-mac-ui-smoke.yml +++ b/.github/workflows/studio-mac-ui-smoke.yml @@ -89,13 +89,8 @@ jobs: set -o pipefail bash install.sh --local --no-torch 2>&1 | tee logs/install.log - - name: Assert install.sh used the Mac llama.cpp prebuilt - run: | - if grep -q "falling back to source build" logs/install.log; then - echo "::error::install.sh fell back to source-build llama.cpp on Mac. Studio must install the prebuilt llama-bNNNN-bin-macos-arm64 on Apple Silicon." - grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60 - exit 1 - fi + - name: Assert llama.cpp loads on this macOS + run: bash .github/scripts/assert-llama-loads.sh - name: Install Playwright + Chromium # No --with-deps on Mac: that flag installs Linux apt packages. diff --git a/.github/workflows/studio-mac-update-smoke.yml b/.github/workflows/studio-mac-update-smoke.yml index b65439f174..1eca227096 100644 --- a/.github/workflows/studio-mac-update-smoke.yml +++ b/.github/workflows/studio-mac-update-smoke.yml @@ -67,21 +67,8 @@ jobs: set -o pipefail bash install.sh --local --no-torch 2>&1 | tee logs/install.log - - name: Assert install.sh used the Mac llama.cpp prebuilt - run: | - # Mac install must take the prebuilt path. Source-build - # fallback here is an Unsloth bug. - if grep -q "falling back to source build" logs/install.log; then - echo "::error::install.sh fell back to source-build llama.cpp on Mac. Studio must install the prebuilt llama-bNNNN-bin-macos-arm64 on Apple Silicon." - grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60 - exit 1 - fi - if ! grep -qE "prebuilt installed and validated|prebuilt up to date and validated|bin-macos-arm64" logs/install.log; then - echo "::error::no Mac prebuilt llama.cpp marker in install.log." - grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60 - exit 1 - fi - echo "install.sh installed the Mac prebuilt llama.cpp" + - name: Assert llama.cpp loads on this macOS + run: bash .github/scripts/assert-llama-loads.sh - name: First update should be a no-op (prebuilt already validated) env: diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py index 9443b13fd7..38fa31fff8 100644 --- a/studio/install_llama_prebuilt.py +++ b/studio/install_llama_prebuilt.py @@ -19,6 +19,7 @@ import re import shutil import site import socket +import struct import subprocess import sys import tarfile @@ -160,6 +161,14 @@ DEFAULT_MAX_PREBUILT_RELEASE_FALLBACKS = env_int( 2, minimum = 1, ) +# Deeper macOS-only walk-back: upstream can ship a run of prebuilts built for a +# newer macOS than the host, only caught at validate time, so an older host must +# skip the whole run. Free on new hosts (first plan validates, extras unused). +DEFAULT_MAX_MACOS_RELEASE_FALLBACKS = env_int( + "UNSLOTH_LLAMA_MAX_MACOS_RELEASE_FALLBACKS", + 16, + minimum = 1, +) FORCE_COMPILE_DEFAULT_REF = os.environ.get("UNSLOTH_LLAMA_FORCE_COMPILE_REF", "master") DIRECT_LINUX_BUNDLE_PROFILES: dict[str, dict[str, Any]] = { @@ -231,6 +240,9 @@ class HostInfo: has_usable_nvidia: bool has_rocm: bool = False rocm_gfx_target: str | None = None + # (major, minor) from platform.mac_ver(); None off macOS or if unparseable. + # Skips a macos prebuilt whose minimum-OS exceeds this host. + macos_version: tuple[int, int] | None = None @dataclass @@ -1528,6 +1540,14 @@ def resolve_simple_install_release_plans( requested_tag == "latest" and not published_release_tag ) release_limit = max(1, max_release_fallbacks) + # macOS may need to walk past a run of too-new prebuilts. Only when the host + # version is known; otherwise keep the default (cannot tell up front). + if ( + host.is_macos + and allow_older_release_fallback + and host.macos_version is not None + ): + release_limit = max(release_limit, DEFAULT_MAX_MACOS_RELEASE_FALLBACKS) plans: list[InstallReleasePlan] = [] last_error: PrebuiltFallback | None = None @@ -2750,6 +2770,8 @@ def detect_host() -> HostInfo: is_x86_64 = machine in {"x86_64", "amd64"} is_arm64 = machine in {"arm64", "aarch64"} + macos_version = parse_macos_version(platform.mac_ver()[0]) if is_macos else None + nvidia_smi = shutil.which("nvidia-smi") driver_cuda_version = None compute_caps: list[str] = [] @@ -2925,6 +2947,7 @@ def detect_host() -> HostInfo: has_usable_nvidia = has_usable_nvidia, has_rocm = has_rocm, rocm_gfx_target = rocm_gfx_target, + macos_version = macos_version, ) @@ -4872,6 +4895,189 @@ def linux_runtime_dirs(binary_path: Path) -> list[str]: return linux_runtime_dirs_for_required_libraries(missing) +# macOS prebuilt compatibility. Upstream macos prebuilts built on a newer macOS +# (e.g. minos=26, referencing Metal-4 symbols) fail dyld load on macOS 14/15. We +# read the host macOS version and each binary's minimum-OS so selection can skip +# a too-new prebuilt and walk back to the newest release that runs on this host. +# Mach-O constants (Apple mach-o/fat.h, mach-o/loader.h, mach/machine.h). +_MACHO_FAT_MAGICS = {0xCAFEBABE, 0xCAFEBABF} # universal binary (32/64-bit fat) +_LC_VERSION_MIN_MACOSX = 0x24 # legacy min-macOS load command +_LC_BUILD_VERSION = 0x32 # modern platform+minos+sdk load command +_MACHO_PLATFORM_MACOS = 1 # LC_BUILD_VERSION platform id for macOS (iOS=2, ...) +# CPU types (base | ABI64); used to pick the host slice in a fat binary. +_CPU_TYPE_X86_64 = 0x01000007 +_CPU_TYPE_ARM64 = 0x0100000C + + +def parse_macos_version(value: str | None) -> tuple[int, int] | None: + """Parse a macOS product version string into (major, minor). + + Handles "14.7.1", "15.5", "26.0" and bare "26". Returns None when the + value is empty or cannot be parsed (callers then defer to runtime + validation rather than rejecting every prebuilt).""" + if not value: + return None + match = re.match(r"\s*(\d+)(?:\.(\d+))?", str(value)) + if not match: + return None + return int(match.group(1)), int(match.group(2) or 0) + + +def host_supports_macos_minos(host: HostInfo, minos: tuple[int, int] | None) -> bool: + """True if a prebuilt requiring `minos` can load on this host. Unknown host + version or unknown minos -> True: let runtime validation decide instead of + rejecting a binary we cannot reason about.""" + if minos is None or host.macos_version is None: + return True + return host.macos_version >= minos + + +def _macho_slice_minos(data: bytes, offset: int) -> tuple[int, int] | None: + """Minimum macOS for a single thin Mach-O at `offset`, via LC_BUILD_VERSION + (platform macOS) or the legacy LC_VERSION_MIN_MACOSX. None if absent.""" + if offset + 4 > len(data): + return None + magic = struct.unpack_from(">I", data, offset)[0] + if magic in (0xFEEDFACE, 0xFEEDFACF): + endian, is64 = ">", magic == 0xFEEDFACF + elif magic in (0xCEFAEDFE, 0xCFFAEDFE): + endian, is64 = "<", magic == 0xCFFAEDFE + else: + return None + header_size = 32 if is64 else 28 + if offset + header_size > len(data): + return None + ncmds = struct.unpack_from(endian + "I", data, offset + 16)[0] + cursor = offset + header_size + for _ in range(ncmds): + if cursor + 8 > len(data): + break + cmd, cmdsize = struct.unpack_from(endian + "II", data, cursor) + if cmdsize < 8: + break + if cmd == _LC_BUILD_VERSION and cursor + 16 <= len(data): + platform_id, minos = struct.unpack_from(endian + "II", data, cursor + 8) + if platform_id == _MACHO_PLATFORM_MACOS: + return (minos >> 16) & 0xFFFF, (minos >> 8) & 0xFF + elif cmd == _LC_VERSION_MIN_MACOSX and cursor + 12 <= len(data): + version = struct.unpack_from(endian + "I", data, cursor + 8)[0] + return (version >> 16) & 0xFFFF, (version >> 8) & 0xFF + cursor += cmdsize + return None + + +def macho_minimum_macos( + path: Path, host: HostInfo | None = None +) -> tuple[int, int] | None: + """Minimum macOS (major, minor) a Mach-O binary or dylib requires. + + Pure-Python so it works on consumer Macs without the Xcode command line + tools (otool/vtool). For universal binaries it prefers the host-arch slice, + else the highest minos found. Returns None for non-Mach-O files or when no + version load command is present.""" + try: + data = path.read_bytes() + except Exception: + return None + if len(data) < 8: + return None + magic = struct.unpack_from(">I", data, 0)[0] + if magic in _MACHO_FAT_MAGICS: + is64 = magic == 0xCAFEBABF + nfat = struct.unpack_from(">I", data, 4)[0] + entry = 8 + slices: list[tuple[int, tuple[int, int]]] = [] + for _ in range(nfat): + if is64: + if entry + 32 > len(data): + break + cputype = struct.unpack_from(">I", data, entry)[0] + slice_offset = struct.unpack_from(">Q", data, entry + 8)[0] + entry += 32 + else: + if entry + 20 > len(data): + break + cputype = struct.unpack_from(">I", data, entry)[0] + slice_offset = struct.unpack_from(">I", data, entry + 8)[0] + entry += 20 + minos = _macho_slice_minos(data, slice_offset) + if minos is not None: + slices.append((cputype, minos)) + if not slices: + return None + if host is not None: + want = ( + _CPU_TYPE_ARM64 + if host.is_arm64 + else (_CPU_TYPE_X86_64 if host.is_x86_64 else None) + ) + for cputype, minos in slices: + if cputype == want: + return minos + return max(minos for _cputype, minos in slices) + return _macho_slice_minos(data, 0) + + +def looks_like_macos_incompatibility(text: str) -> bool: + """True when dyld output means a prebuilt needs a newer macOS than the host + (the runtime backstop for cases the static minos scan cannot read).""" + if not text: + return False + if "built for macOS" in text and "newer than running OS" in text: + return True + return "Symbol not found" in text and "MTLResidency" in text + + +def macos_binary_minos_issues( + binaries: Iterable[Path], + install_dir: Path, + host: HostInfo, +) -> list[str]: + """Issue strings for every installed Mach-O whose minimum macOS exceeds the + host. Scans the given executables plus every bundled .dylib next to them -- + the dyld failure originates in libggml-metal.dylib, not the executable.""" + candidates: list[Path] = list(binaries) + bin_dir = install_dir / "build" / "bin" + if bin_dir.is_dir(): + candidates.extend(sorted(bin_dir.rglob("*.dylib"))) + + issues: list[str] = [] + seen: set[Path] = set() + for path in candidates: + try: + resolved = path.resolve() + except Exception: + resolved = path + if resolved in seen or not path.exists(): + continue + seen.add(resolved) + minos = macho_minimum_macos(path, host) + if minos is not None and not host_supports_macos_minos(host, minos): + issues.append( + f"{path.name}: built for macOS {minos[0]}.{minos[1]} > " + f"host macOS {host.macos_version[0]}.{host.macos_version[1]}" + ) + return issues + + +def preflight_macos_installed_binaries( + binaries: Iterable[Path], + install_dir: Path, + host: HostInfo, +) -> None: + """Reject a macos prebuilt whose minimum-OS is newer than the host so the + release walk-back advances to the newest compatible release. No-op when the + host macOS version is unknown (runtime validation remains the backstop).""" + if not host.is_macos or host.macos_version is None: + return + issues = macos_binary_minos_issues(binaries, install_dir, host) + if issues: + raise PrebuiltFallback( + "macos prebuilt requires a newer macOS than this host:\n" + + "\n".join(issues) + ) + + def preflight_linux_installed_binaries( binaries: Iterable[Path], install_dir: Path, @@ -5030,10 +5236,17 @@ def validate_quantize( or not quantized_path.exists() or quantized_path.stat().st_size == 0 ): + combined = result.stdout + ("\n" + result.stderr if result.stderr else "") + # Backstop for prebuilts the static minos scan could not read: a dyld + # "built for macOS N" / missing Metal symbol failure means this binary + # needs a newer macOS than the host, so fall back to an older release. + prefix = ( + "macos prebuilt requires a newer macOS than this host: " + if looks_like_macos_incompatibility(combined) + else "" + ) raise PrebuiltFallback( - "llama-quantize validation failed:\n" - + result.stdout - + ("\n" + result.stderr if result.stderr else "") + prefix + "llama-quantize validation failed:\n" + combined ) @@ -5451,6 +5664,14 @@ def resolve_install_release_plans( requested_tag == "latest" and not published_release_tag ) release_limit = max(1, max_release_fallbacks) + # macOS may need to walk past a run of too-new prebuilts. Only when the host + # version is known; otherwise keep the default (cannot tell up front). + if ( + host.is_macos + and allow_older_release_fallback + and host.macos_version is not None + ): + release_limit = max(release_limit, DEFAULT_MAX_MACOS_RELEASE_FALLBACKS) plans: list[InstallReleasePlan] = [] last_error: PrebuiltFallback | None = None @@ -5835,6 +6056,7 @@ def validate_prebuilt_choice( choice, host, install_dir, work_dir ) preflight_linux_installed_binaries((server_path, quantize_path), install_dir, host) + preflight_macos_installed_binaries((server_path, quantize_path), install_dir, host) ensure_repo_shape(install_dir) write_prebuilt_metadata( install_dir, diff --git a/studio/setup.sh b/studio/setup.sh index 89489f38b0..3a149d465f 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -1084,6 +1084,15 @@ else _IS_MACOS_ARM64=true fi + # macOS: pin a low deployment target so the source build loads on + # older macOS too (else a macOS 26 host stamps minos=26). Set before + # CPU_FALLBACK_CMAKE_ARGS copies CMAKE_ARGS so both paths inherit it. + if [ "$_HOST_SYSTEM" = "Darwin" ]; then + _MACOS_DEPLOYMENT_TARGET="${UNSLOTH_MACOS_DEPLOYMENT_TARGET:-13.3}" + CMAKE_ARGS="$CMAKE_ARGS -DCMAKE_OSX_DEPLOYMENT_TARGET=${_MACOS_DEPLOYMENT_TARGET}" + export MACOSX_DEPLOYMENT_TARGET="${_MACOS_DEPLOYMENT_TARGET}" + fi + if command -v ccache &>/dev/null; then CMAKE_ARGS="$CMAKE_ARGS -DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache -DCMAKE_CUDA_COMPILER_LAUNCHER=ccache" fi diff --git a/tests/studio/install/test_macos_version_compat.py b/tests/studio/install/test_macos_version_compat.py new file mode 100644 index 0000000000..0adc91a622 --- /dev/null +++ b/tests/studio/install/test_macos_version_compat.py @@ -0,0 +1,322 @@ +"""Tests for the host-macOS-version-aware llama.cpp prebuilt selection added +for the Mac "Failing CI" fix. + +Covers: parse_macos_version, host_supports_macos_minos, the pure-Python Mach-O +minimum-OS parser (macho_minimum_macos), the dyld-incompatibility classifier, +the install preflight that rejects a too-new prebuilt, and the deeper macOS +release walk-back in resolve_simple_install_release_plans. + +No GPU, no network, no torch, no real Mach-O toolchain required -- the Mach-O +samples are synthesized in-process and all I/O is monkeypatched. +""" + +import importlib.util +import struct +import sys +from pathlib import Path + +import pytest + + +PACKAGE_ROOT = Path(__file__).resolve().parents[3] +MODULE_PATH = PACKAGE_ROOT / "studio" / "install_llama_prebuilt.py" +SPEC = importlib.util.spec_from_file_location( + "studio_install_llama_prebuilt_macos", MODULE_PATH +) +assert SPEC is not None and SPEC.loader is not None +ILP = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = ILP +SPEC.loader.exec_module(ILP) + +HostInfo = ILP.HostInfo +PrebuiltFallback = ILP.PrebuiltFallback + +_CPU_TYPE_ARM64 = 0x0100000C +_CPU_TYPE_X86_64 = 0x01000007 + + +def make_macos_host(macos_version, *, arm64 = True): + return HostInfo( + system = "Darwin", + machine = "arm64" if arm64 else "x86_64", + is_windows = False, + is_linux = False, + is_macos = True, + is_x86_64 = not arm64, + is_arm64 = arm64, + nvidia_smi = None, + driver_cuda_version = None, + compute_caps = [], + visible_cuda_devices = None, + has_physical_nvidia = False, + has_usable_nvidia = False, + macos_version = macos_version, + ) + + +def thin_macho(minos = (14, 0), *, cputype = _CPU_TYPE_ARM64, build_version = True): + """Synthesize a minimal little-endian 64-bit Mach-O carrying a macOS + minimum-version load command.""" + encoded = (minos[0] << 16) | (minos[1] << 8) + if build_version: + # LC_BUILD_VERSION: cmd, cmdsize, platform(=1 macOS), minos, sdk, ntools + load_command = struct.pack("<6I", 0x32, 24, 1, encoded, encoded, 0) + else: + # LC_VERSION_MIN_MACOSX: cmd, cmdsize, version, sdk + load_command = struct.pack("<4I", 0x24, 16, encoded, encoded) + header = struct.pack("<8I", 0xFEEDFACF, cputype, 0, 0x2, 1, len(load_command), 0, 0) + return header + load_command + + +def fat_macho(slices): + """Synthesize a big-endian universal binary from (cputype, thin_bytes).""" + header = struct.pack(">2I", 0xCAFEBABE, len(slices)) + data_offset = 8 + 20 * len(slices) + arch_entries = b"" + body = b"" + for cputype, thin in slices: + offset = data_offset + len(body) + arch_entries += struct.pack(">5I", cputype, 0, offset, len(thin), 0) + body += thin + return header + arch_entries + body + + +class TestParseMacosVersion: + @pytest.mark.parametrize( + "value, expected", + [ + ("14.7.1", (14, 7)), + ("15.5", (15, 5)), + ("26.0", (26, 0)), + ("26", (26, 0)), + ("13", (13, 0)), + ("", None), + (None, None), + ("not-a-version", None), + ], + ) + def test_parse(self, value, expected): + assert ILP.parse_macos_version(value) == expected + + +class TestHostSupportsMacosMinos: + def test_older_host_rejects_newer_prebuilt(self): + assert not ILP.host_supports_macos_minos(make_macos_host((14, 0)), (26, 0)) + + def test_same_version_supported(self): + assert ILP.host_supports_macos_minos(make_macos_host((26, 0)), (26, 0)) + + def test_newer_host_supports_older_prebuilt(self): + assert ILP.host_supports_macos_minos(make_macos_host((15, 5)), (14, 0)) + + def test_unknown_host_defers_to_runtime(self): + assert ILP.host_supports_macos_minos(make_macos_host(None), (26, 0)) + + def test_unknown_minos_defers_to_runtime(self): + assert ILP.host_supports_macos_minos(make_macos_host((14, 0)), None) + + +class TestMachoMinimumMacos: + def test_build_version_thin(self, tmp_path): + path = tmp_path / "lib.dylib" + path.write_bytes(thin_macho((26, 0))) + assert ILP.macho_minimum_macos(path) == (26, 0) + + def test_legacy_version_min_thin(self, tmp_path): + path = tmp_path / "lib.dylib" + path.write_bytes(thin_macho((14, 0), build_version = False)) + assert ILP.macho_minimum_macos(path) == (14, 0) + + def test_universal_prefers_host_arch_slice(self, tmp_path): + # arm64 slice needs macOS 14, x86_64 slice needs macOS 26. + path = tmp_path / "fat" + path.write_bytes( + fat_macho( + [ + (_CPU_TYPE_ARM64, thin_macho((14, 0), cputype = _CPU_TYPE_ARM64)), + (_CPU_TYPE_X86_64, thin_macho((26, 0), cputype = _CPU_TYPE_X86_64)), + ] + ) + ) + assert ILP.macho_minimum_macos(path, make_macos_host((14, 0))) == (14, 0) + assert ILP.macho_minimum_macos(path, make_macos_host((26, 0), arm64 = False)) == ( + 26, + 0, + ) + + def test_non_macho_returns_none(self, tmp_path): + path = tmp_path / "script.sh" + path.write_bytes(b'#!/bin/sh\nexec real "$@"\n') + assert ILP.macho_minimum_macos(path) is None + + def test_missing_file_returns_none(self, tmp_path): + assert ILP.macho_minimum_macos(tmp_path / "nope") is None + + +class TestLooksLikeMacosIncompatibility: + def test_built_for_newer_os(self): + assert ILP.looks_like_macos_incompatibility( + "dyld: ... (built for macOS 26.0 which is newer than running OS)" + ) + + def test_metal_residency_symbol(self): + assert ILP.looks_like_macos_incompatibility( + "Symbol not found: _OBJC_CLASS_$_MTLResidencySetDescriptor" + ) + + def test_benign_error(self): + assert not ILP.looks_like_macos_incompatibility("some unrelated failure") + + def test_empty(self): + assert not ILP.looks_like_macos_incompatibility("") + + +class TestPreflightMacosInstalledBinaries: + def _install_dir(self, tmp_path, dylib_minos): + bin_dir = tmp_path / "build" / "bin" + bin_dir.mkdir(parents = True) + (bin_dir / "libggml-metal.dylib").write_bytes(thin_macho(dylib_minos)) + server = tmp_path / "llama-server" + server.write_bytes(thin_macho(dylib_minos)) + quantize = tmp_path / "llama-quantize" + quantize.write_bytes(thin_macho(dylib_minos)) + return tmp_path, (server, quantize) + + def test_rejects_too_new_dylib(self, tmp_path): + install_dir, binaries = self._install_dir(tmp_path, (26, 0)) + with pytest.raises(PrebuiltFallback, match = "newer macOS"): + ILP.preflight_macos_installed_binaries( + binaries, install_dir, make_macos_host((14, 0)) + ) + + def test_accepts_compatible_prebuilt(self, tmp_path): + install_dir, binaries = self._install_dir(tmp_path, (14, 0)) + # Must not raise on a macOS 15 host. + ILP.preflight_macos_installed_binaries( + binaries, install_dir, make_macos_host((15, 5)) + ) + + def test_skips_when_host_version_unknown(self, tmp_path): + install_dir, binaries = self._install_dir(tmp_path, (26, 0)) + # Unknown host version -> defer to runtime validation, do not raise. + ILP.preflight_macos_installed_binaries( + binaries, install_dir, make_macos_host(None) + ) + + def test_noop_on_non_macos_host(self, tmp_path): + install_dir, binaries = self._install_dir(tmp_path, (26, 0)) + linux_host = HostInfo( + system = "Linux", + machine = "x86_64", + is_windows = False, + is_linux = True, + is_macos = False, + is_x86_64 = True, + is_arm64 = False, + nvidia_smi = None, + driver_cuda_version = None, + compute_caps = [], + visible_cuda_devices = None, + has_physical_nvidia = False, + has_usable_nvidia = False, + ) + ILP.preflight_macos_installed_binaries(binaries, install_dir, linux_host) + + +def _fake_macos_releases(tags): + return [ + { + "tag_name": tag, + "assets": [ + { + "name": f"llama-{tag}-bin-macos-arm64.tar.gz", + "browser_download_url": f"https://example.com/{tag}.tar.gz", + } + ], + } + for tag in tags + ] + + +class TestMacosReleaseWalkback: + """A known-version macOS host must generate enough older-release plans to + walk back past a run of too-new prebuilts; unknown-version and non-macOS + hosts keep the conservative 2-release default.""" + + TAGS = [f"b{n}" for n in range(9437, 9400, -1)] # 37 newest-first releases + + def _patch_releases(self, monkeypatch): + monkeypatch.setattr( + ILP, + "iter_release_payloads_by_time", + lambda repo, published_release_tag, requested_tag: _fake_macos_releases( + self.TAGS + ), + ) + + def test_known_macos_host_walks_back_deeper(self, monkeypatch): + self._patch_releases(monkeypatch) + _tag, plans = ILP.resolve_simple_install_release_plans( + "latest", + make_macos_host((14, 0)), + "ggml-org/llama.cpp", + "", + ) + assert len(plans) == ILP.DEFAULT_MAX_MACOS_RELEASE_FALLBACKS + assert len(plans) > ILP.DEFAULT_MAX_PREBUILT_RELEASE_FALLBACKS + + def test_unknown_macos_host_uses_default(self, monkeypatch): + self._patch_releases(monkeypatch) + _tag, plans = ILP.resolve_simple_install_release_plans( + "latest", + make_macos_host(None), + "ggml-org/llama.cpp", + "", + ) + assert len(plans) == ILP.DEFAULT_MAX_PREBUILT_RELEASE_FALLBACKS + + +class TestForwardsBackwardsCompat: + """The gate is host >= prebuilt minos with no hardcoded version, so it holds + for older and future macOS alike. Emulate the walk-back over a release set + spanning several minos tiers and assert each host takes the newest release + it can load.""" + + # Newest first: future 27 builds, current 26 builds, an old 14 tier, a 13. + RELEASES = [ + ("b9600", (27, 0)), + ("b9450", (26, 0)), + ("b9415", (14, 0)), + ("b8300", (13, 0)), + ] + + def _select(self, tmp_path, host_version): + for tag, minos in self.RELEASES: + bin_dir = tmp_path / tag / "build" / "bin" + bin_dir.mkdir(parents = True) + (bin_dir / "libggml-metal.dylib").write_bytes(thin_macho(minos)) + try: + ILP.preflight_macos_installed_binaries( + (), tmp_path / tag, make_macos_host(host_version) + ) + return tag + except PrebuiltFallback: + continue + return None + + @pytest.mark.parametrize( + "host_version, expected", + [ + ((13, 0), "b8300"), # older host takes the older prebuilt + ((14, 7), "b9415"), # backwards: skip 26/27, take newest that loads + ((15, 5), "b9415"), + ((26, 0), "b9450"), # unchanged: newest <= host + ((27, 1), "b9600"), # forwards: future host takes the future build + ], + ) + def test_selects_newest_loadable(self, tmp_path, host_version, expected): + assert self._select(tmp_path, host_version) == expected + + def test_host_below_prebuilt_floor_falls_through(self, tmp_path): + # macOS 12 is below every prebuilt -> nothing matches -> source build. + assert self._select(tmp_path, (12, 0)) is None