Skip to content

Commit c5c38e0

Browse files
authored
Merge pull request #77 from ausimian/fix/atomic-mlx-install
Make the MLX build robust and reorganise emily's caches
2 parents 08faa69 + 6fd8962 commit c5c38e0

6 files changed

Lines changed: 214 additions & 20 deletions

File tree

.github/workflows/ci.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ jobs:
3838
env:
3939
MIX_ENV: test
4040
EMILY_MLX_VARIANT: ${{ matrix.variant }}
41+
# Pin the cache to the path the actions/cache step below
42+
# populates. Without it, mix.exs's macOS default of
43+
# DARWIN_USER_CACHE_DIR (`/private/var/folders/...`) misses
44+
# the restored cache and rebuilds MLX from scratch every run.
45+
# mix.exs runs `Path.expand` on this, so `~` resolves correctly.
46+
EMILY_CACHE: ~/Library/Caches/emily
4147
steps:
4248
- uses: actions/checkout@v6
4349

@@ -114,6 +120,12 @@ jobs:
114120
env:
115121
MIX_ENV: test
116122
EMILY_MLX_VARIANT: ${{ matrix.variant }}
123+
# Pin the cache to the path the actions/cache step below
124+
# populates. Without it, mix.exs's macOS default of
125+
# DARWIN_USER_CACHE_DIR (`/private/var/folders/...`) misses
126+
# the restored cache and rebuilds MLX from scratch every run.
127+
# mix.exs runs `Path.expand` on this, so `~` resolves correctly.
128+
EMILY_CACHE: ~/Library/Caches/emily
117129
steps:
118130
- uses: actions/checkout@v6
119131

.github/workflows/release-nif.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,11 @@ jobs:
8585
MIX_ENV: dev
8686
EMILY_MLX_VARIANT: ${{ matrix.variant }}
8787
VERSION: ${{ needs.prepare-release.outputs.version }}
88+
# Pin the cache to the path the actions/cache step below
89+
# populates. Without it, mix.exs's macOS default of
90+
# DARWIN_USER_CACHE_DIR (`/private/var/folders/...`) misses
91+
# the restored cache and rebuilds MLX from scratch every run.
92+
EMILY_CACHE: ~/Library/Caches/emily
8893
steps:
8994
- uses: actions/checkout@v6
9095

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ PRIV_DIR := $(MIX_APP_PATH)/priv
22
NIF_SO := $(PRIV_DIR)/libemily.so
33
METALLIB := $(PRIV_DIR)/mlx.metallib
44

5-
BUILD_DIR := $(EMILY_CACHE_DIR)/build-$(EMILY_VERSION)
5+
BUILD_DIR := $(MIX_APP_PATH)/obj
66

77
# Sources — include ops/* and any other subdirs under c_src.
88
SOURCES := $(shell find c_src -name '*.cpp')

RELEASE.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,19 @@
1616
set `config :emily, variant: :jit` in their own
1717
`config/config.exs`. The old `:mlx_variant` key and
1818
`config/local.exs` override are gone.
19+
- macOS default cache location moves from `~/Library/Caches/emily/`
20+
to `DARWIN_USER_CACHE_DIR` (`/private/var/folders/<hash>/C/emily`)
21+
— the per-user sandboxed cache root Apple's own sandboxed apps
22+
use. Persistent across reboots, lives outside `~/Library/`.
23+
Linux / Windows still use the XDG convention. Override via
24+
`EMILY_CACHE`. Existing macOS users can `rm -rf
25+
~/Library/Caches/emily/` to reclaim the orphaned data after
26+
upgrade.
27+
- NIF object files move from the user-level cache to
28+
`$(MIX_APP_PATH)/obj/` (i.e. `_build/<env>/lib/emily/obj/`). As a
29+
consequence, plain `mix clean` now correctly removes them via the
30+
existing Makefile rule — they were previously left behind because
31+
`make clean` didn't see the cache-dir env vars.
1932

2033
### Added
2134

@@ -24,6 +37,40 @@
2437
uploads tarball + `.sha256` sidecar to a draft GitHub release.
2538
`workflow_dispatch` is also wired for out-of-band rebuilds
2639
(artefacts go to workflow storage; the release is untouched).
40+
- `mix clean.mlx` — wipes the MLX install dir(s) under the cache.
41+
Plain `mix clean` deliberately preserves them since rebuilding
42+
MLX from source is ~5-7 minutes.
43+
44+
### Fixed
45+
46+
- MLX source builds are now atomic. The build script installs into
47+
`${PREFIX}.staging` and only `mv`s onto the final path after the
48+
artefact sanity checks pass; an EXIT trap wipes the scratch dirs
49+
on failure. Previously, an interrupted build (Ctrl-C, killed
50+
process, concurrent run) left an empty install dir that
51+
subsequent `mix compile` runs misread as "MLX is already
52+
installed", silently skipping the build and bombing out in
53+
`elixir_make` with `make: *** No rule to make target
54+
'.../mlx.metallib'`. The compile-time check now requires both
55+
`lib/libmlx.a` and `lib/mlx.metallib` to be present before
56+
trusting the dir.
57+
- Concurrent invocations of `build-mlx.sh` against the same install
58+
prefix are now serialised via a `mkdir`-based lock with
59+
stale-PID reclaim. ElixirLS uses its own build path
60+
(`.elixir_ls/build/...`) so an LSP-driven `mix compile` and a CLI
61+
`mix compile.emily_mlx --force` lock on *different*
62+
`Mix.Project.with_build_lock` keys and freely raced into the same
63+
MLX cache dir, clobbering each other's `${PREFIX}.build/`
64+
mid-build and surfacing as `clang ... Rename failed: ... No such
65+
file or directory` during Metal-shader compilation.
66+
- CMake's FetchContent sub-build of metal_cpp / json / fmt during
67+
configure runs with `CMAKE_BUILD_PARALLEL_LEVEL=1`, dodging a
68+
race in its download → extract → rename → stamp-touch pipeline
69+
that surfaced as `getcwd: cannot access parent directories`
70+
followed by `cd: <dir>/_deps: No such file or directory`. The
71+
main MLX build still runs at full NCPU jobs.
72+
- The MLX scratch build dir (`${PREFIX}.build`) is preserved on
73+
configure failure so `CMakeError.log` survives for diagnostics.
2774

2875
### Removed
2976

mix.exs

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -143,10 +143,27 @@ defmodule Emily.MixProject do
143143
"test"
144144
],
145145
"compile.emily_mlx": &build_mlx/1,
146-
"compile.emily_nif": &fetch_nif/1
146+
"compile.emily_nif": &fetch_nif/1,
147+
# The MLX install dir lives in the user-level cache and is
148+
# *deliberately* preserved across `mix clean` (rebuilding from
149+
# source is ~5–7 min). Wipe it explicitly with `mix clean.mlx`.
150+
"clean.mlx": &clean_mlx/1
147151
]
148152
end
149153

154+
defp clean_mlx(_args) do
155+
case Path.wildcard(Path.join(cache_dir(), "mlx-*")) do
156+
[] ->
157+
Mix.shell().info("No MLX install dirs to clean in #{cache_dir()}")
158+
159+
dirs ->
160+
for dir <- dirs do
161+
File.rm_rf!(dir)
162+
Mix.shell().info("Removed #{dir}")
163+
end
164+
end
165+
end
166+
150167
defp docs do
151168
[
152169
main: "readme",
@@ -196,19 +213,36 @@ defmodule Emily.MixProject do
196213
"MLX_DIR" => dir,
197214
"MLX_INCLUDE_DIR" => Path.join(dir, "include"),
198215
"MLX_LIB_DIR" => Path.join(dir, "lib"),
199-
"FINE_INCLUDE_DIR" => Fine.include_dir(),
200-
"EMILY_CACHE_DIR" => cache_dir(),
201-
"EMILY_VERSION" => @version
216+
"FINE_INCLUDE_DIR" => Fine.include_dir()
202217
}
203218
end
204219

220+
# Default cache location, override with `EMILY_CACHE`. On macOS we
221+
# use `DARWIN_USER_CACHE_DIR` (`/private/var/folders/<hash>/C/emily`)
222+
# — the per-user sandboxed cache root that Apple's own sandboxed apps
223+
# use for transient-but-persistent state. It's per-user, persistent
224+
# across reboots (unlike `/tmp`), and lives outside `~/Library/` so
225+
# it's not subject to user-facing backup/sync tooling defaults.
226+
# Linux / Windows fall back to the XDG convention.
205227
defp cache_dir do
206228
case System.get_env("EMILY_CACHE") do
207-
nil -> :filename.basedir(:user_cache, ~c"emily") |> to_string()
229+
nil -> default_cache_dir()
208230
dir -> Path.expand(dir)
209231
end
210232
end
211233

234+
defp default_cache_dir do
235+
case :os.type() do
236+
{:unix, :darwin} ->
237+
{out, 0} = System.cmd("getconf", ["DARWIN_USER_CACHE_DIR"])
238+
Path.join(String.trim(out), "emily")
239+
240+
_ ->
241+
cache_home = System.get_env("XDG_CACHE_HOME") || Path.join(System.user_home!(), ".cache")
242+
Path.join(cache_home, "emily")
243+
end
244+
end
245+
212246
defp mlx_variant do
213247
case Application.get_env(:emily, :variant, :aot) do
214248
:aot -> "aot"
@@ -247,14 +281,23 @@ defmodule Emily.MixProject do
247281
File.rm_rf!(dir)
248282
end
249283

250-
if File.dir?(dir) do
284+
if mlx_installed?(dir) do
251285
{:ok, []}
252286
else
287+
File.rm_rf!(dir)
253288
build_mlx_from_source!(dir)
254289
{:ok, []}
255290
end
256291
end
257292

293+
# `build-mlx.sh` publishes via `mv staging prefix` only after the
294+
# critical artefacts are in place, so a directory missing either of
295+
# these is by definition a partial install — don't trust it.
296+
defp mlx_installed?(dir) do
297+
File.exists?(Path.join([dir, "lib", "libmlx.a"])) and
298+
File.exists?(Path.join([dir, "lib", "mlx.metallib"]))
299+
end
300+
258301
defp build_mlx_from_source!(install_dir) do
259302
mlx_src = Path.expand("deps/mlx_src", File.cwd!())
260303

scripts/build-mlx.sh

Lines changed: 100 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -59,19 +59,96 @@ EOF
5959
fi
6060
fi
6161

62-
# Keep the scratch build dir alongside the install prefix so `--force`
63-
# from mix.exs (which rm -rf's the install dir) doesn't orphan it.
62+
# Serialise concurrent script invocations against the same install
63+
# prefix. Mix.Project.with_build_lock can't help here: ElixirLS uses
64+
# its own build path (.elixir_ls/build/...) so an LSP-driven
65+
# `mix compile` and a CLI `mix compile.emily_mlx --force` lock on
66+
# *different* keys and freely race into the same MLX cache dir. Both
67+
# invocations would then rm each other's `${PREFIX}.build/` mid-build,
68+
# surfacing as `clang ... Rename failed: ... No such file or
69+
# directory` during Metal-shader compilation.
70+
#
71+
# `flock(1)` isn't shipped on macOS, so we use atomic `mkdir` as the
72+
# lock primitive. The lock dir is keyed on PREFIX, which both
73+
# contexts share. A PID file inside lets us reclaim a stale lock if
74+
# the previous holder died without cleanup.
6475
BUILD_DIR="${PREFIX}.build"
76+
STAGING="${PREFIX}.staging"
77+
LOCK_DIR="${PREFIX}.lock"
78+
LOCK_PID_FILE="${LOCK_DIR}/pid"
79+
80+
mkdir -p "$(dirname "$LOCK_DIR")"
81+
82+
acquired_lock=0
83+
printed_wait=0
84+
85+
# Define cleanup + install the trap *before* the lock-acquisition loop
86+
# so that a concurrent-winner short-circuit (or any other early exit)
87+
# still releases LOCK_DIR. STAGING is a half-baked install — always
88+
# wipe. BUILD_DIR holds CMakeFiles/ and CMakeError.log on failure;
89+
# keep it on non-zero exit so diagnostics survive.
90+
cleanup() {
91+
local exit_code=$?
92+
rm -rf "$STAGING"
93+
if (( acquired_lock == 1 )); then
94+
rm -rf "$LOCK_DIR"
95+
fi
96+
if [[ $exit_code -eq 0 ]]; then
97+
rm -rf "$BUILD_DIR"
98+
else
99+
echo "==> Build failed (exit ${exit_code}); preserving ${BUILD_DIR} for diagnostics" >&2
100+
fi
101+
}
102+
trap cleanup EXIT
103+
104+
while :; do
105+
if mkdir "$LOCK_DIR" 2>/dev/null; then
106+
echo "$$" > "$LOCK_PID_FILE"
107+
acquired_lock=1
108+
break
109+
fi
65110

66-
rm -rf "$BUILD_DIR" "$PREFIX"
67-
mkdir -p "$BUILD_DIR" "$PREFIX"
111+
holder=""
112+
[[ -r "$LOCK_PID_FILE" ]] && holder=$(cat "$LOCK_PID_FILE" 2>/dev/null || true)
113+
114+
if [[ -n "$holder" ]] && ! kill -0 "$holder" 2>/dev/null; then
115+
echo "==> Reclaiming stale MLX-build lock (dead PID $holder)" >&2
116+
rm -rf "$LOCK_DIR"
117+
continue
118+
fi
119+
120+
if (( printed_wait == 0 )); then
121+
echo "==> Waiting for concurrent MLX build${holder:+ (PID $holder)} on ${PREFIX}" >&2
122+
printed_wait=1
123+
fi
124+
sleep 1
125+
done
126+
127+
# A concurrent winner may have completed the install while we waited
128+
# for the lock — re-check and short-circuit if so.
129+
if [[ -f "${PREFIX}/lib/libmlx.a" && -f "${PREFIX}/lib/mlx.metallib" ]]; then
130+
echo "==> MLX already installed at ${PREFIX} (concurrent build won)"
131+
exit 0
132+
fi
133+
134+
rm -rf "$BUILD_DIR" "$STAGING"
135+
mkdir -p "$BUILD_DIR" "$STAGING"
68136

69137
echo "==> Configuring MLX ${VERSION} (${VARIANT})"
70-
cmake \
138+
# Configure triggers `FetchContent_MakeAvailable` for metal_cpp / json /
139+
# fmt, which CMake implements via a recursive `cmake --build` of a tiny
140+
# sub-project per dep. Those sub-builds inherit `CMAKE_BUILD_PARALLEL_LEVEL`
141+
# and race on FetchContent's download → extract → rename → stamp-touch
142+
# pipeline when run in parallel — observed as `getcwd: cannot access
143+
# parent directories` followed by `cd: <dir>/_deps: No such file or
144+
# directory` (FetchContent renames out from under a sub-shell that the
145+
# parallel make spawned). Pin the env to 1 only for this invocation;
146+
# the main MLX build below still runs at NCPU jobs.
147+
CMAKE_BUILD_PARALLEL_LEVEL=1 cmake \
71148
-S "$MLX_SRC_DIR" \
72149
-B "$BUILD_DIR" \
73150
-DCMAKE_BUILD_TYPE=Release \
74-
-DCMAKE_INSTALL_PREFIX="$PREFIX" \
151+
-DCMAKE_INSTALL_PREFIX="$STAGING" \
75152
-DBUILD_SHARED_LIBS=OFF \
76153
-DMLX_BUILD_TESTS=OFF \
77154
-DMLX_BUILD_EXAMPLES=OFF \
@@ -86,21 +163,31 @@ NCPU="$(sysctl -n hw.ncpu)"
86163
echo "==> Building with ${NCPU} jobs"
87164
cmake --build "$BUILD_DIR" --parallel "$NCPU"
88165

89-
echo "==> Installing into ${PREFIX}"
166+
echo "==> Installing into ${STAGING}"
90167
cmake --install "$BUILD_DIR"
91168

92169
# MLX's Metal device loader looks for mlx.metallib colocated with the
93170
# binary. cmake --install places it under lib/ (via the install rules
94-
# in mlx/backend/metal/CMakeLists.txt); sanity-check both artefacts.
171+
# in mlx/backend/metal/CMakeLists.txt); sanity-check both artefacts
172+
# before we rename staging into place.
95173
for f in "lib/libmlx.a" "lib/mlx.metallib"; do
96-
if [[ ! -f "${PREFIX}/${f}" ]]; then
97-
echo "error: expected ${PREFIX}/${f} after cmake --install (build is incomplete)" >&2
174+
if [[ ! -f "${STAGING}/${f}" ]]; then
175+
echo "error: expected ${STAGING}/${f} after cmake --install (build is incomplete)" >&2
98176
exit 1
99177
fi
100178
done
101179

102-
# Scratch build is large (~1.5 GB on the aot lane). mix.exs doesn't
103-
# need it once the install dir is populated.
104-
rm -rf "$BUILD_DIR"
180+
# Atomic publish: rename staging to prefix. If a racing build already
181+
# populated PREFIX, `mv` refuses to overwrite the non-empty target and
182+
# exits non-zero — defer to the winner, our trap cleans STAGING up.
183+
echo "==> Publishing to ${PREFIX}"
184+
if ! mv "$STAGING" "$PREFIX"; then
185+
if [[ -f "${PREFIX}/lib/libmlx.a" && -f "${PREFIX}/lib/mlx.metallib" ]]; then
186+
echo "==> ${PREFIX} already populated by a concurrent build; keeping it"
187+
else
188+
echo "error: failed to publish staging to ${PREFIX}" >&2
189+
exit 1
190+
fi
191+
fi
105192

106193
echo "==> Done: ${PREFIX}"

0 commit comments

Comments
 (0)