Skip to content

Commit f735fd3

Browse files
committed
Fix SIGABRT/SIGSEGV from concurrent mx::eval dispatch
MLX is not thread-safe (ml-explore/mlx#2133). The Metal CommandEncoder is shared state — concurrent mx::eval calls from different OS threads crash with "A command encoder is already encoding to this command buffer" (SIGABRT) or SIGSEGV. The M14 soak tests were the first to exercise concurrent eval from multiple dirty-CPU scheduler threads. Fix: - Add emily::safe_eval() with a static mutex serialising all mx::eval calls (c_src/emily/tensor.hpp) - Remove set_default_stream calls from with_stream/2 — the NIF mutated MLX thread-local state which is unreliable under BEAM process migration between OS threads - Harden resolve_stream(-1) to avoid reading thread-local default Also adds M15.5 milestone for building MLX from source once upstream gains native thread-safety (0.32+).
1 parent 8df48f1 commit f735fd3

6 files changed

Lines changed: 108 additions & 20 deletions

File tree

PLAN.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -674,6 +674,31 @@ silent about it and ships a tested pattern.
674674
**Exit:** both patterns documented; concurrency soak demonstrates
675675
the streamed path is stable.
676676

677+
**Post-M14 note — eval serialisation:**
678+
MLX is not thread-safe (ml-explore/mlx#2133). The Metal
679+
`CommandEncoder` is shared state — concurrent `mx::eval` calls from
680+
different OS threads crash with `"A command encoder is already
681+
encoding to this command buffer"` (SIGABRT) or SIGSEGV from corrupted
682+
encoder state. The M14 soak tests (8 concurrent workers via
683+
`Task.async_stream`) exposed this by being the first code path to call
684+
`mx::eval` from multiple dirty-CPU scheduler threads simultaneously.
685+
686+
Fixed by:
687+
1. **`emily::safe_eval()`**: a mutex-serialised `mx::eval` wrapper in
688+
`c_src/emily/tensor.hpp`. All eval callsites route through it.
689+
Graph-building ops (regular scheduler) remain lock-free.
690+
2. **Removed `set_default_stream` from `with_stream/2`**: the NIF
691+
mutated MLX thread-local state on BEAM scheduler threads, which
692+
is unreliable since BEAM processes migrate between OS threads.
693+
The process-dictionary-based stream routing was already correct.
694+
3. **Hardened `resolve_stream(-1)`**: the -1 fallback now reads the
695+
device default directly instead of the (potentially corrupted)
696+
thread-local default.
697+
698+
The mutex serialises Metal dispatch at the cost of true concurrent
699+
GPU execution. See M15.5 (MLX upgrade) for the plan to restore
700+
concurrency via MLX's native thread-local `CommandEncoder` support.
701+
677702
### M15 — Native linalg
678703

679704
`lu`, `svd`, `qr`, `cholesky`, `triangular_solve`, `eigh`,
@@ -698,6 +723,37 @@ BinaryBackend-slow. MLX exposes most natively under `mx::linalg::*`.
698723
**Exit:** all `mx::linalg::*`-backed callbacks pass property suite;
699724
remaining `via_binary` linalg paths documented with rationale.
700725

726+
### M15.5 — MLX upgrade (build from source)
727+
728+
Emily pins MLX 0.25.1 via pre-built binaries from `cocoa-xu/mlx-build`.
729+
MLX gained native thread-safety on `main` in April 2026 (thread-local
730+
`CommandEncoder` ml-explore/mlx#3348, `ThreadLocalStream` C++ API
731+
ml-explore/mlx#3405), but neither fix is in any release yet (latest:
732+
0.31.1). Building from source unblocks true concurrent Metal dispatch
733+
and removes the `safe_eval` mutex introduced in the post-M14 fix.
734+
735+
- **Build MLX from source** (from `main` or 0.32+ when released)
736+
instead of fetching the pre-built 0.25.1 tarball. Extend `mix.exs`
737+
to support an `MLX_SOURCE` env var pointing at a local MLX build.
738+
- **Audit API changes** between 0.25.1 and target version. Emily's
739+
C++ surface is narrow (core ops, linalg, streams, eval, allocator),
740+
but six months of MLX releases may rename or remove functions.
741+
- **Adopt `ThreadLocalStream` C++ API**: each BEAM dirty-CPU thread
742+
gets its own MLX stream automatically, enabling true per-thread
743+
Metal command queues without the eval mutex.
744+
- **Remove `emily::safe_eval` mutex** once native thread-safety is
745+
validated — revert to direct `mx::eval` calls.
746+
747+
**Testing**:
748+
- Amplified stress test (16 workers, 100 iterations) passes without
749+
mutex under the new MLX build.
750+
- Full test suite 20x with zero crashes.
751+
- Benchmark `to_binary` latency under concurrent load: confirm
752+
throughput improves vs. the mutex-serialised path.
753+
754+
**Exit:** concurrent soak tests pass without mutex; MLX build-from-source
755+
documented; stress test confirms concurrent Metal dispatch is stable.
756+
701757
### M16 — Mixed-precision training
702758

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

RELEASE.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
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.
515
- Relax MNIST convergence canary threshold from 97% to 96% to eliminate
616
stochastic flaps (observed 96.99% on occasional runs). The test is a
717
sanity gate, not a performance benchmark.

c_src/emily/tensor.hpp

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

1717
#include <cstdint>
18+
#include <mutex>
1819
#include <stdexcept>
1920
#include <string>
2021
#include <vector>
@@ -60,12 +61,38 @@ unwrap_all(const std::vector<fine::ResourcePtr<Tensor>> &tensors) {
6061
}
6162

6263
// Resolve a stream index from Elixir into an mx::Stream.
63-
// -1 (the sentinel for "no explicit stream") falls through to the
64-
// thread-local default — backwards-compatible with pre-M14 code paths.
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.
6569
inline mx::Stream resolve_stream(int64_t stream_index) {
6670
if (stream_index < 0)
67-
return mx::default_stream(mx::default_device());
71+
return mx::default_stream(mx::Device(mx::Device::DeviceType::gpu));
6872
return mx::get_stream(static_cast<int>(stream_index));
6973
}
7074

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+
7198
} // namespace emily

c_src/emily_nif.cpp

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ FINE_NIF(from_binary, 0);
7979
fine::Term to_binary(ErlNifEnv *env, fine::ResourcePtr<Tensor> tensor, int64_t s) {
8080
auto stream = emily::resolve_stream(s);
8181
auto materialized = mx::contiguous(tensor->array, false, stream);
82-
mx::eval(materialized);
82+
emily::safe_eval(materialized);
8383

8484
// Defensive: mx::contiguous is supposed to give a row-contiguous
8585
// layout. If it ever doesn't, we'd be aliasing a strided buffer and
@@ -113,7 +113,7 @@ FINE_NIF(dtype, 0);
113113
// eval/1 — force evaluation of the lazy graph rooted at this tensor.
114114
// Dirty CPU: waits for MLX to finish.
115115
fine::Ok<> eval(ErlNifEnv *, fine::ResourcePtr<Tensor> tensor) {
116-
mx::eval(tensor->array);
116+
emily::safe_eval(tensor->array);
117117
return fine::Ok<>{};
118118
}
119119
FINE_NIF(eval, ERL_NIF_DIRTY_JOB_CPU_BOUND);

c_src/stream.cpp

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,11 @@ int64_t new_stream(ErlNifEnv *, fine::Atom device_atom) {
3131
FINE_NIF(new_stream, 0);
3232

3333
// set_default_stream/1 — set the thread-local default stream.
34-
// Belt-and-suspenders for Emily.Stream.with_stream/2; every op NIF
35-
// also receives the stream index explicitly.
34+
//
35+
// WARNING: BEAM processes migrate between OS threads, so thread-local
36+
// state is unreliable. with_stream/2 no longer calls this — it routes
37+
// streams via the process dictionary instead. This NIF is retained for
38+
// advanced use cases but should be avoided in normal code.
3639
fine::Ok<> set_default_stream(ErlNifEnv *, int64_t stream_index) {
3740
mx::set_default_stream(mx::get_stream(static_cast<int>(stream_index)));
3841
return fine::Ok<>{};

lib/emily/stream.ex

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,9 @@ defmodule Emily.Stream do
2323
`with_stream/2` stores the stream index in the process dictionary.
2424
`Emily.Backend` reads it via `Process.get(:emily_stream, -1)` and
2525
passes it as an explicit argument to every NIF call. Each NIF
26-
resolves the index to an `mx::Stream` (or falls back to the
27-
thread-local default when the index is -1). This avoids the
28-
thread-local race that would occur if we relied solely on
29-
`set_default_stream` — BEAM processes can migrate between OS
26+
resolves the index to an `mx::Stream` (or falls back to the default
27+
GPU stream when the index is -1). This is process-dictionary based,
28+
not thread-local, because BEAM processes migrate between OS
3029
scheduler threads between NIF calls.
3130
3231
## Concurrent serving patterns
@@ -73,18 +72,13 @@ defmodule Emily.Stream do
7372
Execute `fun` with the given stream as the default for MLX ops.
7473
7574
Stores the stream index in the process dictionary so that
76-
`Emily.Backend` passes it to every NIF call. Also sets the
77-
thread-local default stream as a belt-and-suspenders measure for
78-
code that calls `Emily.Native` directly.
79-
80-
The previous stream (if any) is restored in an `after` block, so
81-
nesting is safe.
75+
`Emily.Backend` passes it to every NIF call. The previous stream
76+
(if any) is restored in an `after` block, so nesting is safe.
8277
"""
8378
@spec with_stream(t(), (-> result)) :: result when result: var
8479
def with_stream(%__MODULE__{index: index}, fun) when is_function(fun, 0) do
8580
prev = Process.get(:emily_stream)
8681
Process.put(:emily_stream, index)
87-
Emily.Native.set_default_stream(index)
8882

8983
try do
9084
fun.()
@@ -93,8 +87,6 @@ defmodule Emily.Stream do
9387
nil -> Process.delete(:emily_stream)
9488
idx -> Process.put(:emily_stream, idx)
9589
end
96-
97-
if prev, do: Emily.Native.set_default_stream(prev)
9890
end
9991
end
10092

0 commit comments

Comments
 (0)