Skip to content

Commit dcb4cf8

Browse files
authored
Merge pull request #203 from ausimian/build/pin-macos-deployment-target
build: pin explicit per-variant macOS deployment targets
2 parents e434f89 + 36d531b commit dcb4cf8

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)