Skip to content

Commit ce4a91e

Browse files
authored
Merge pull request #28 from ausimian/investigate-sigsegv-streams
Fix SIGABRT/SIGSEGV from concurrent mx::eval dispatch
2 parents 8df48f1 + f735fd3 commit ce4a91e

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)