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.
This commit is contained in:
parent
1016fff1d6
commit
366937de44
9 changed files with 705 additions and 53 deletions
57
.github/scripts/assert-llama-loads.sh
vendored
Executable file
57
.github/scripts/assert-llama-loads.sh
vendored
Executable file
|
|
@ -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
|
||||
9
.github/workflows/studio-mac-api-smoke.yml
vendored
9
.github/workflows/studio-mac-api-smoke.yml
vendored
|
|
@ -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'
|
||||
|
|
|
|||
27
.github/workflows/studio-mac-inference-smoke.yml
vendored
27
.github/workflows/studio-mac-inference-smoke.yml
vendored
|
|
@ -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'
|
||||
|
|
|
|||
80
.github/workflows/studio-mac-install-matrix.yml
vendored
Normal file
80
.github/workflows/studio-mac-install-matrix.yml
vendored
Normal file
|
|
@ -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
|
||||
9
.github/workflows/studio-mac-ui-smoke.yml
vendored
9
.github/workflows/studio-mac-ui-smoke.yml
vendored
|
|
@ -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.
|
||||
|
|
|
|||
17
.github/workflows/studio-mac-update-smoke.yml
vendored
17
.github/workflows/studio-mac-update-smoke.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
322
tests/studio/install/test_macos_version_compat.py
Normal file
322
tests/studio/install/test_macos_version_compat.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue