Skip to content

Commit c0f14ca

Browse files
authored
Merge pull request #29 from ausimian/m14.5-mlx-from-source
M14.5: worker-thread dispatch for vendored MLX
2 parents ce4a91e + e3c9fac commit c0f14ca

44 files changed

Lines changed: 1525 additions & 1514 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,19 +23,15 @@ jobs:
2323
MIX_ENV: test
2424
steps:
2525
- uses: actions/checkout@v4
26+
with:
27+
submodules: recursive
2628

2729
- name: Setup Beam
2830
uses: erlef/setup-beam@v1
2931
with:
3032
version-file: .tool-versions
3133
version-type: strict
3234

33-
- name: Cache MLX prebuilt
34-
uses: actions/cache@v4
35-
with:
36-
path: ~/Library/Caches/emily
37-
key: mlx-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('mix.exs') }}
38-
3935
- name: Cache deps
4036
uses: actions/cache@v4
4137
with:
@@ -47,7 +43,7 @@ jobs:
4743
uses: actions/cache@v4
4844
with:
4945
path: _build
50-
key: build-${{ runner.os }}-${{ hashFiles('mix.lock', 'c_src/**', 'Makefile', '.tool-versions') }}
46+
key: build-${{ runner.os }}-${{ hashFiles('mix.lock', 'c_src/**', 'Makefile', '.tool-versions', '.gitmodules') }}
5147
restore-keys: build-${{ runner.os }}-
5248

5349
# The Bumblebee cache holds tiny-random HuggingFace fixtures

.gitmodules

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[submodule "vendor/mlx"]
2+
path = vendor/mlx
3+
url = https://github.com/ml-explore/mlx.git

Makefile

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
PRIV_DIR := $(MIX_APP_PATH)/priv
22
NIF_SO := $(PRIV_DIR)/libemily.so
3-
MLX_STAGE_DIR := $(PRIV_DIR)/mlx/lib
3+
METALLIB := $(PRIV_DIR)/mlx.metallib
44

55
BUILD_DIR := $(EMILY_CACHE_DIR)/build-$(EMILY_VERSION)
66

@@ -17,14 +17,18 @@ CXXFLAGS += -I$(ERTS_INCLUDE_DIR) -Ic_src
1717
# -Werror.
1818
CXXFLAGS += -isystem $(FINE_INCLUDE_DIR) -isystem $(MLX_INCLUDE_DIR)
1919

20-
LDFLAGS := -L$(MLX_LIB_DIR) -lmlx -shared
20+
# Static link: embed libmlx.a into the NIF and link system frameworks
21+
# that MLX depends on directly (previously resolved transitively through
22+
# the dylib).
23+
LDFLAGS := -shared
24+
LDFLAGS += $(MLX_LIB_DIR)/libmlx.a
2125

2226
UNAME_S := $(shell uname -s)
2327
ifeq ($(UNAME_S),Darwin)
24-
LDFLAGS += -undefined dynamic_lookup -flat_namespace -rpath @loader_path/mlx/lib
28+
LDFLAGS += -undefined dynamic_lookup -flat_namespace
29+
LDFLAGS += -framework Metal -framework Foundation -framework Accelerate
2530
JOBS := $(shell sysctl -n hw.ncpu)
2631
else
27-
LDFLAGS += -Wl,-rpath,'$$ORIGIN/mlx/lib'
2832
JOBS := $(shell nproc)
2933
endif
3034

@@ -43,7 +47,7 @@ MAKE_JOBS ?= $(JOBS)
4347

4448
.PHONY: all clean bench-native
4549

46-
all: $(NIF_SO)
50+
all: $(NIF_SO) $(METALLIB)
4751

4852
# ------------------------------------------------------------------
4953
# bench-native: standalone C++ microbenchmarks under bench/native/.
@@ -61,8 +65,8 @@ $(BENCH_NATIVE_BIN): $(BENCH_NATIVE_SRC) | $(BUILD_DIR)
6165
$(CXX) -std=c++17 -O3 -Wall -Wextra \
6266
-isystem $(MLX_INCLUDE_DIR) \
6367
$(BENCH_NATIVE_SRC) \
64-
-L$(MLX_LIB_DIR) -lmlx \
65-
-Wl,-rpath,$(MLX_LIB_DIR) \
68+
$(MLX_LIB_DIR)/libmlx.a \
69+
-framework Metal -framework Foundation -framework Accelerate \
6670
-o $(BENCH_NATIVE_BIN)
6771

6872
bench-native: $(BENCH_NATIVE_BIN)
@@ -79,12 +83,14 @@ $(BUILD_DIR)/%.o: c_src/%.cpp $(HEADERS) | $(BUILD_DIR)
7983
@mkdir -p $(dir $@)
8084
$(CXX) $(CXXFLAGS) -c $< -o $@
8185

82-
$(MLX_STAGE_DIR): | $(PRIV_DIR)
83-
@mkdir -p $(MLX_STAGE_DIR)
84-
@cp -a $(MLX_LIB_DIR)/. $(MLX_STAGE_DIR)/
85-
86-
$(NIF_SO): $(OBJECTS) $(MLX_STAGE_DIR) | $(PRIV_DIR)
86+
$(NIF_SO): $(OBJECTS) | $(PRIV_DIR)
8787
$(CXX) $(OBJECTS) -o $(NIF_SO) $(LDFLAGS)
8888

89+
# MLX searches for mlx.metallib colocated with the loaded binary
90+
# (see vendor/mlx/mlx/backend/metal/device.cpp:load_default_library).
91+
# Stage the compiled shader library next to the NIF .so in priv/.
92+
$(METALLIB): $(MLX_LIB_DIR)/mlx.metallib | $(PRIV_DIR)
93+
cp $< $@
94+
8995
clean:
90-
rm -rf $(BUILD_DIR) $(NIF_SO) $(PRIV_DIR)/mlx
96+
rm -rf $(BUILD_DIR) $(NIF_SO) $(METALLIB)

PLAN.md

Lines changed: 26 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -696,34 +696,10 @@ Fixed by:
696696
thread-local default.
697697

698698
The mutex serialises Metal dispatch at the cost of true concurrent
699-
GPU execution. See M15.5 (MLX upgrade) for the plan to restore
699+
GPU execution. See M14.5 (MLX upgrade) for the plan to restore
700700
concurrency via MLX's native thread-local `CommandEncoder` support.
701701

702-
### M15 — Native linalg
703-
704-
`lu`, `svd`, `qr`, `cholesky`, `triangular_solve`, `eigh`,
705-
`determinant`, and friends route through `via_binary` today. Correct,
706-
BinaryBackend-slow. MLX exposes most natively under `mx::linalg::*`.
707-
708-
- Bind each available `mx::linalg::*` function as a Native NIF.
709-
- Replace the `via_binary` Backend callbacks with Native dispatch.
710-
- Document divergences (MLX's pivot strategy may differ from Nx's
711-
reference; numerical conditioning thresholds may differ).
712-
713-
**Testing**:
714-
- Native unit tests against hand-computed references for small
715-
matrices (3×3, 4×4) where the answer is checkable.
716-
- Backend property tests vs. `Nx.BinaryBackend` with shape generators
717-
biased toward well-conditioned inputs (random Gaussian → QR →
718-
reconstruct). Document the conditioning-bound failure mode for
719-
ill-conditioned cases.
720-
- Existing `via_binary` fallbacks for any op MLX doesn't implement
721-
natively; add a fallback-coverage test for the residual.
722-
723-
**Exit:** all `mx::linalg::*`-backed callbacks pass property suite;
724-
remaining `via_binary` linalg paths documented with rationale.
725-
726-
### M15.5 — MLX upgrade (build from source)
702+
### M14.5 — MLX upgrade (build from source)
727703

728704
Emily pins MLX 0.25.1 via pre-built binaries from `cocoa-xu/mlx-build`.
729705
MLX gained native thread-safety on `main` in April 2026 (thread-local
@@ -754,6 +730,30 @@ and removes the `safe_eval` mutex introduced in the post-M14 fix.
754730
**Exit:** concurrent soak tests pass without mutex; MLX build-from-source
755731
documented; stress test confirms concurrent Metal dispatch is stable.
756732

733+
### M15 — Native linalg
734+
735+
`lu`, `svd`, `qr`, `cholesky`, `triangular_solve`, `eigh`,
736+
`determinant`, and friends route through `via_binary` today. Correct,
737+
BinaryBackend-slow. MLX exposes most natively under `mx::linalg::*`.
738+
739+
- Bind each available `mx::linalg::*` function as a Native NIF.
740+
- Replace the `via_binary` Backend callbacks with Native dispatch.
741+
- Document divergences (MLX's pivot strategy may differ from Nx's
742+
reference; numerical conditioning thresholds may differ).
743+
744+
**Testing**:
745+
- Native unit tests against hand-computed references for small
746+
matrices (3×3, 4×4) where the answer is checkable.
747+
- Backend property tests vs. `Nx.BinaryBackend` with shape generators
748+
biased toward well-conditioned inputs (random Gaussian → QR →
749+
reconstruct). Document the conditioning-bound failure mode for
750+
ill-conditioned cases.
751+
- Existing `via_binary` fallbacks for any op MLX doesn't implement
752+
natively; add a fallback-coverage test for the residual.
753+
754+
**Exit:** all `mx::linalg::*`-backed callbacks pass property suite;
755+
remaining `via_binary` linalg paths documented with rationale.
756+
757757
### M16 — Mixed-precision training
758758

759759
bf16 activations + f32 master weights + loss scaling is the standard

RELEASE.md

Lines changed: 38 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,39 +2,50 @@
22

33
## Fixed
44

5-
- **Fix SIGABRT/SIGSEGV from concurrent `mx::eval` dispatch.** MLX's
6-
Metal `CommandEncoder` is not thread-safe (ml-explore/mlx#2133).
7-
Concurrent `mx::eval` calls from BEAM dirty-CPU scheduler threads
8-
triggered `"A command encoder is already encoding"` assertions or
9-
SIGSEGV from corrupted encoder state. Fixed by serialising all
10-
`mx::eval` calls through `emily::safe_eval()` (mutex in
11-
`c_src/emily/tensor.hpp`). Also removed `set_default_stream` calls
12-
from `with_stream/2` — the NIF mutated MLX thread-local state which
13-
is unreliable under BEAM process migration. Hardened
14-
`resolve_stream(-1)` to avoid reading the thread-local default.
155
- Relax MNIST convergence canary threshold from 97% to 96% to eliminate
166
stochastic flaps (observed 96.99% on occasional runs). The test is a
177
sanity gate, not a performance benchmark.
188

199
## Added
2010

21-
- M14 — Serving concurrency: stream-per-process. `Emily.Stream` lets
22-
each BEAM process use its own Metal command queue for concurrent
23-
inference. `Emily.Stream.new/1` creates a stream,
24-
`Emily.Stream.with_stream/2` scopes all ops in a block to that
25-
stream, and `Emily.Stream.synchronize/1` waits for completion.
26-
The stream index is passed explicitly to every op NIF (no
27-
thread-local race) via a `-1` sentinel for "use default stream"
28-
(backwards-compatible). `Emily.Compiler.__partitions_options__/1`
29-
error message now points to `Emily.Stream`.
30-
- **New files**: `c_src/stream.cpp` (4 stream management NIFs),
31-
`lib/emily/stream.ex` (`Emily.Stream` struct + API),
32-
`test/emily/stream_test.exs`, `test/soak/stream_concurrency_test.exs`.
33-
- **Modified**: every op NIF gained a trailing `int64_t s` stream
34-
parameter; `Emily.Native` stubs, `Emily.Backend`, and all test
35-
files updated accordingly.
36-
- README now documents both concurrency patterns (stream-per-process
37-
and pooled servings).
11+
- M14.5 — Worker-thread dispatch for vendored MLX. Replaces the
12+
stream-index NIF convention (M14) and the `safe_eval` mutex with a
13+
proper per-stream dedicated OS thread. Each `WorkerThread` (C++ class
14+
in `c_src/emily/worker.hpp`) owns an MLX stream and its Metal
15+
`CommandEncoder` on a single thread; NIFs dispatch work via
16+
`run_sync` (promise/future, blocks caller for ~1-10 µs). This
17+
eliminates the thread-local `CommandEncoder` mismatch that caused
18+
SIGABRT/SIGSEGV under BEAM process migration, and removes the global
19+
eval mutex that serialised all GPU work.
20+
- **MLX built from source.** MLX is vendored as a git submodule
21+
(`vendor/mlx`, pinned to commit `8e649be4`). The Makefile builds
22+
`libmlx.a` via cmake and statically links it into the NIF. The
23+
Metal shader library (`mlx.metallib`) is staged into `priv/` at
24+
compile time. No prebuilt download step.
25+
- **`Emily.MlxStream`** GenServer owns the default `WorkerThread`
26+
resource under the application supervisor. `default_worker/0`
27+
caches the worker ref in the process dictionary to avoid per-op
28+
GenServer calls. User-created streams (`Emily.Stream.new/1`)
29+
allocate their own worker; `enif_monitor_process` stops the thread
30+
when the creating process exits.
31+
- **NIF signature change**: every op NIF takes a worker ref as its
32+
first parameter (after env) instead of a stream index as its last.
33+
`Emily.Native`, `Emily.Backend`, and all test files updated
34+
accordingly. `c_src/stream.cpp` (old stream management NIFs)
35+
deleted.
36+
- **Concurrent Metal dispatch.** Multiple `WorkerThread`s =
37+
multiple MLX streams = concurrent Metal command queues. Model
38+
weights (`mx::array`) are shared across threads (refcounted,
39+
thread-safe reads). Shared data must be materialised before
40+
passing across workers (lazy tensors are bound to their creating
41+
worker's stream).
42+
- **New files**: `c_src/emily/worker.hpp` (WorkerThread class),
43+
`c_src/worker_nif.cpp` (resource registration with `down`
44+
callback), `lib/emily/mlx_stream.ex` (GenServer),
45+
`test/soak/eval_concurrency_test.exs`.
46+
- **`Emily.Stream`** rewritten: struct holds a `worker` ref instead
47+
of an integer index; `with_stream/2` stores it in the process
48+
dictionary under `:emily_worker`; `synchronize/1` removed.
3849

3950
- M13 — EXLA gradient conformance. Adds a third gradient oracle —
4051
EXLA (XLA CPU backend) — to catch bugs where Emily and BinaryBackend

c_src/emily/tensor.hpp

Lines changed: 0 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
#include <mlx/mlx.h>
1616

1717
#include <cstdint>
18-
#include <mutex>
1918
#include <stdexcept>
2019
#include <string>
2120
#include <vector>
@@ -60,39 +59,4 @@ unwrap_all(const std::vector<fine::ResourcePtr<Tensor>> &tensors) {
6059
return out;
6160
}
6261

63-
// Resolve a stream index from Elixir into an mx::Stream.
64-
// -1 (the sentinel for "no explicit stream") returns the default GPU
65-
// stream (index 0). We intentionally avoid mx::default_stream() here
66-
// because that reads a thread-local which can be corrupted on BEAM
67-
// scheduler threads — the BEAM migrates processes between OS threads,
68-
// so thread-local state is unreliable.
69-
inline mx::Stream resolve_stream(int64_t stream_index) {
70-
if (stream_index < 0)
71-
return mx::default_stream(mx::Device(mx::Device::DeviceType::gpu));
72-
return mx::get_stream(static_cast<int>(stream_index));
73-
}
74-
75-
// MLX is not thread-safe (ml-explore/mlx#2133). In particular, the
76-
// Metal CommandEncoder is shared state — concurrent mx::eval calls
77-
// from different OS threads crash with "A command encoder is already
78-
// encoding to this command buffer". BEAM dirty-CPU schedulers are a
79-
// thread pool, so concurrent to_binary / eval NIF calls race.
80-
//
81-
// Serialise all mx::eval calls behind a single mutex until MLX gains
82-
// native thread-safety (expected 0.32+, see ml-explore/mlx#3348).
83-
inline std::mutex &eval_mutex() {
84-
static std::mutex m;
85-
return m;
86-
}
87-
88-
inline void safe_eval(mx::array &a) {
89-
std::lock_guard<std::mutex> lock(eval_mutex());
90-
mx::eval(a);
91-
}
92-
93-
inline void safe_eval(std::initializer_list<mx::array> arrays) {
94-
std::lock_guard<std::mutex> lock(eval_mutex());
95-
mx::eval(arrays);
96-
}
97-
9862
} // namespace emily

0 commit comments

Comments
 (0)