Skip to content

Commit 36d531b

Browse files
committed
build: pin explicit per-variant macOS deployment targets
The MLX build and NIF link set no deployment target, so MLX's objects inherited the build host's OS (CMake "build for host") while the NIF linked at the SDK-major .0 — producing `ld` version-mismatch warnings and a shipped floor that drifts with the runner OS. An aot build done on a macOS-26 host silently linked the macOS-26 libSystem `_Float16` helpers (`__fmaxf16`/`__fminf16`) as hard imports, so it would fail to dyld-load on the older macOS the aot variant is meant to support. Pin an explicit per-variant floor (aot 14.0; jit 26.2, since its NAX kernels and JACCL are gated on macOS SDK >= 26.2) and thread it through both the MLX CMake build (CMAKE_OSX_DEPLOYMENT_TARGET) and the NIF compile/link (MACOSX_DEPLOYMENT_TARGET) from one source of truth in mix.exs. The floor is folded into the MLX cache dir name so a change can't reuse a stale build. scripts/verify-nif-floor.sh asserts the built NIF's LC_BUILD_VERSION and that a sub-26 floor imports no macOS-26 symbols; CI and the release workflow run it per variant. Closes #202.
1 parent e434f89 commit 36d531b

6 files changed

Lines changed: 115 additions & 10 deletions

File tree

.github/workflows/ci.yml

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,10 @@ jobs:
3838
include:
3939
- os: macos-14
4040
variant: aot
41+
min: "14.0"
4142
- os: macos-26
4243
variant: jit
44+
min: "26.2"
4345
env:
4446
MIX_ENV: test
4547
EMILY_MLX_VARIANT: ${{ matrix.variant }}
@@ -79,7 +81,7 @@ jobs:
7981
uses: actions/cache@v5
8082
with:
8183
path: ~/Library/Caches/emily
82-
key: mlx-${{ runner.os }}-${{ matrix.variant }}-${{ hashFiles('c_src/**', 'Makefile', 'mix.exs') }}
84+
key: mlx-${{ runner.os }}-${{ matrix.variant }}-${{ hashFiles('c_src/**', 'Makefile', 'mix.exs', 'scripts/build-mlx.sh') }}
8385
restore-keys: mlx-${{ runner.os }}-${{ matrix.variant }}-
8486

8587
# The Bumblebee cache holds tiny-random HuggingFace fixtures
@@ -97,6 +99,12 @@ jobs:
9799

98100
- run: mix precommit
99101

102+
# Assert the built NIF declares the pinned macOS floor for this variant
103+
# and imports no above-floor libSystem symbols (see the script). Catches
104+
# a variant that silently requires a newer macOS than it targets.
105+
- name: Verify NIF macOS floor
106+
run: bash scripts/verify-nif-floor.sh _build/test/lib/emily/priv/libemily.so "${{ matrix.min }}"
107+
100108
# Conformance tests are excluded from the default suite because
101109
# they require network access on a cold cache (see
102110
# test/test_helper.exs). In CI we always want them green — a
@@ -120,8 +128,10 @@ jobs:
120128
include:
121129
- os: macos-14
122130
variant: aot
131+
min: "14.0"
123132
- os: macos-26
124133
variant: jit
134+
min: "26.2"
125135
env:
126136
MIX_ENV: test
127137
EMILY_MLX_VARIANT: ${{ matrix.variant }}
@@ -158,7 +168,7 @@ jobs:
158168
uses: actions/cache@v5
159169
with:
160170
path: ~/Library/Caches/emily
161-
key: mlx-${{ runner.os }}-${{ matrix.variant }}-${{ hashFiles('c_src/**', 'Makefile', 'mix.exs') }}
171+
key: mlx-${{ runner.os }}-${{ matrix.variant }}-${{ hashFiles('c_src/**', 'Makefile', 'mix.exs', 'scripts/build-mlx.sh') }}
162172
restore-keys: mlx-${{ runner.os }}-${{ matrix.variant }}-
163173

164174
# Full checkpoints land here (Bumblebee + scidata MNIST).

.github/workflows/release-nif.yml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,8 @@ jobs:
7878
# sources transitively include
7979
# <MetalPerformancePrimitives/MetalPerformancePrimitives.h>,
8080
# first shipped in the macOS 26.2 SDK.
81-
- { variant: aot, target: macos-arm64, runs-on: macos-14 }
82-
- { variant: jit, target: macos-arm64, runs-on: macos-26 }
81+
- { variant: aot, target: macos-arm64, runs-on: macos-14, min: "14.0" }
82+
- { variant: jit, target: macos-arm64, runs-on: macos-26, min: "26.2" }
8383
runs-on: ${{ matrix.runs-on }}
8484
env:
8585
MIX_ENV: dev
@@ -118,6 +118,11 @@ jobs:
118118

119119
- run: mix compile
120120

121+
# Guard the shipped artifact: assert it declares the pinned macOS floor
122+
# and imports no above-floor libSystem symbols before it's packaged.
123+
- name: Verify NIF macOS floor
124+
run: bash scripts/verify-nif-floor.sh _build/dev/lib/emily/priv/libemily.so "${{ matrix.min }}"
125+
121126
- name: Package NIF tarball
122127
id: pack
123128
env:

RELEASE.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
### Changed
22

3+
- The precompiled NIF now declares an explicit minimum macOS per variant —
4+
macOS 14 for the AOT build, macOS 26.2 for the JIT build — instead of
5+
inheriting whatever macOS the release runner happened to be on. Published
6+
artifacts now have a deterministic macOS compatibility floor, and CI
7+
asserts it on every build.
38
- Updated the pinned MLX to 0.32.0. This is a maintenance bump that also
49
picks up faster small-batch quantized matvec (`qmv_wide`) — accelerating
510
the fused quantized path — and broader fused SDPA coverage (asymmetric

mix.exs

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,14 @@ defmodule Emily.MixProject do
348348
"MLX_DIR" => dir,
349349
"MLX_INCLUDE_DIR" => Path.join(dir, "include"),
350350
"MLX_LIB_DIR" => Path.join(dir, "lib"),
351-
"FINE_INCLUDE_DIR" => Fine.include_dir()
351+
"FINE_INCLUDE_DIR" => Fine.include_dir(),
352+
# Compile + link the NIF at the same macOS floor the vendored libmlx.a
353+
# was built for (see `macos_deployment_target/0`). clang honours this
354+
# env var for both the object compiles and the final link, so the NIF
355+
# and the static MLX objects agree on `LC_BUILD_VERSION` — no `ld`
356+
# version-mismatch warnings — and the shipped .so declares a
357+
# deterministic minimum rather than inheriting the build host's OS.
358+
"MACOSX_DEPLOYMENT_TARGET" => macos_deployment_target()
352359
}
353360
end
354361

@@ -426,7 +433,31 @@ defmodule Emily.MixProject do
426433
end
427434

428435
defp mlx_install_dir,
429-
do: Path.join(cache_dir(), "mlx-#{@mlx_version}-#{mlx_variant()}")
436+
do:
437+
Path.join(
438+
cache_dir(),
439+
"mlx-#{@mlx_version}-#{mlx_variant()}-min#{macos_deployment_target()}"
440+
)
441+
442+
# Minimum macOS each MLX variant targets, pinned explicitly rather than
443+
# inheriting the build host's OS version. Threaded into both halves of the
444+
# build — the MLX source build via `CMAKE_OSX_DEPLOYMENT_TARGET`
445+
# (see scripts/build-mlx.sh) and the NIF compile/link via
446+
# `MACOSX_DEPLOYMENT_TARGET` (see make_env/0) — and folded into the cache
447+
# dir name above so changing a floor can't silently reuse a stale MLX build.
448+
#
449+
# * aot — the older-macOS-compatible path (built on macOS 14 in CI).
450+
# MLX's own hard floor is 14.0.
451+
# * jit — 26.2: its NAX kernels and the JACCL backend are gated on macOS
452+
# SDK >= 26.2 (deps/mlx_src/CMakeLists.txt), and the macOS-26 libSystem
453+
# `_Float16` helpers (`__fmaxf16`/`__fminf16`) it links aren't present on
454+
# earlier releases, so it isn't portable below 26.2.
455+
defp macos_deployment_target do
456+
case mlx_variant() do
457+
"jit" -> "26.2"
458+
_ -> "14.0"
459+
end
460+
end
430461

431462
defp arch_tag do
432463
case {:os.type(), :erlang.system_info(:system_architecture) |> to_string()} do
@@ -495,7 +526,7 @@ defmodule Emily.MixProject do
495526
:binary,
496527
:exit_status,
497528
:stderr_to_stdout,
498-
{:args, [mlx_src, @mlx_version, jit_flag, install_dir]}
529+
{:args, [mlx_src, @mlx_version, jit_flag, install_dir, macos_deployment_target()]}
499530
]
500531

501532
port = Port.open({:spawn_executable, String.to_charlist(script)}, port_opts)

scripts/build-mlx.sh

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# `compile.emily_mlx` compiler step; not intended for direct use.
55
#
66
# Usage:
7-
# scripts/build-mlx.sh <mlx-src-dir> <mlx-version> <jit 0|1> <install-prefix>
7+
# scripts/build-mlx.sh <mlx-src-dir> <mlx-version> <jit 0|1> <install-prefix> <macos-min>
88
#
99
# <install-prefix> ends up with an {include,lib} layout that mix.exs
1010
# exports to the NIF build via `MLX_INCLUDE_DIR` / `MLX_LIB_DIR`.
@@ -17,15 +17,20 @@ set -euo pipefail
1717
# ninja) still resolve.
1818
export PATH="/usr/bin:/bin:/usr/sbin:/sbin:${PATH}"
1919

20-
if [[ $# -ne 4 ]]; then
21-
echo "usage: $0 <mlx-src-dir> <mlx-version> <jit 0|1> <install-prefix>" >&2
20+
if [[ $# -ne 5 ]]; then
21+
echo "usage: $0 <mlx-src-dir> <mlx-version> <jit 0|1> <install-prefix> <macos-min>" >&2
2222
exit 2
2323
fi
2424

2525
MLX_SRC_DIR="$1"
2626
VERSION="$2"
2727
JIT="$3"
2828
PREFIX="$4"
29+
# Explicit macOS deployment target (e.g. 14.0 for aot, 26.2 for jit). Pins the
30+
# floor for MLX's objects instead of MLX's default "build for the host" (its
31+
# CMakeLists sets CMAKE_OSX_DEPLOYMENT_TARGET to the builder's OS otherwise),
32+
# so the artifacts don't drift with the runner and match the NIF link target.
33+
MACOS_MIN="$5"
2934

3035
case "$JIT" in
3136
0) VARIANT="aot"; METAL_JIT="OFF" ;;
@@ -178,6 +183,7 @@ CMAKE_BUILD_PARALLEL_LEVEL=1 cmake \
178183
-S "$MLX_SRC_DIR" \
179184
-B "$BUILD_DIR" \
180185
-DCMAKE_BUILD_TYPE=Release \
186+
-DCMAKE_OSX_DEPLOYMENT_TARGET="$MACOS_MIN" \
181187
-DCMAKE_INSTALL_PREFIX="$STAGING" \
182188
-DBUILD_SHARED_LIBS=OFF \
183189
-DMLX_BUILD_TESTS=OFF \

scripts/verify-nif-floor.sh

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
#!/usr/bin/env bash
2+
# Assert a built NIF's macOS floor matches the pinned deployment target, and
3+
# that it imports no libSystem symbols newer than that floor. Run by CI after
4+
# `mix compile` (and usable locally) so the shipped precompiled NIF's minimum
5+
# macOS is a checked invariant rather than an accident of the build host.
6+
#
7+
# Guards specifically against the failure mode that motivated the pin: an aot
8+
# build done on a macOS-26 host silently linking the 26.0 `_Float16` helpers
9+
# `__fmaxf16`/`__fminf16` (hard, non-weak imports from libSystem), which would
10+
# fail to dyld-load on the older macOS the aot variant is supposed to support.
11+
#
12+
# Usage: scripts/verify-nif-floor.sh <libemily.so> <expected-min>
13+
# e.g. scripts/verify-nif-floor.sh _build/test/lib/emily/priv/libemily.so 14.0
14+
15+
set -euo pipefail
16+
17+
if [[ $# -ne 2 ]]; then
18+
echo "usage: $0 <libemily.so> <expected-min>" >&2
19+
exit 2
20+
fi
21+
22+
SO="$1"
23+
EXPECTED="$2"
24+
25+
[[ -f "$SO" ]] || { echo "error: NIF not found: $SO" >&2; exit 2; }
26+
27+
# 1) Declared minimum — LC_BUILD_VERSION's `minos`.
28+
minos=$(otool -l "$SO" | awk '/LC_BUILD_VERSION/{f=1} f && /^ *minos/{print $2; exit}')
29+
if [[ "$minos" != "$EXPECTED" ]]; then
30+
echo "error: $SO declares minos '${minos:-<none>}', expected '$EXPECTED'" >&2
31+
exit 1
32+
fi
33+
echo "ok: $SO declares minos $EXPECTED"
34+
35+
# 2) No above-floor libSystem symbols. macOS 26 added the `_Float16` helpers
36+
# __fmaxf16/__fminf16; a sub-26 floor must not import them (they resolve
37+
# from libSystem at load and are absent on earlier macOS).
38+
major=${EXPECTED%%.*}
39+
if (( major < 26 )); then
40+
if nm -u "$SO" 2>/dev/null | grep -qE '___(fmaxf16|fminf16)'; then
41+
echo "error: $SO (floor $EXPECTED) imports macOS-26 _Float16 symbols:" >&2
42+
nm -u "$SO" | grep -E '___(fmaxf16|fminf16)' >&2
43+
exit 1
44+
fi
45+
echo "ok: $SO imports no macOS-26 _Float16 symbols"
46+
fi
47+
48+
echo "NIF floor verified: $SO -> macOS $EXPECTED"

0 commit comments

Comments
 (0)