Skip to content

Commit f22c689

Browse files
authored
Merge pull request #25 from ausimian/m14-stream-per-process
M14: stream-per-process concurrent inference
2 parents 5984e57 + fca87e5 commit f22c689

35 files changed

Lines changed: 1305 additions & 692 deletions

PLAN.md

Lines changed: 20 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ checklist so future-us understands the trade-offs.
2626
scope from M9; mixed-precision master weights: in scope from M16.)
2727
- Drop-in replacement for EMLX. We borrow where it's clearly right, but
2828
we're not constrained by its API.
29-
- `Emily.Stream` as a public API — MLX streams stay internal in v1.
29+
- `Emily.Stream` beyond the narrow `with_stream/2` + `new/1` +
30+
`synchronize/1` surface M14 introduces for the documented
31+
"big model, multi-process serving" pattern.
3032

3133
## Architecture
3234

@@ -635,7 +637,7 @@ checked in alongside the goldens.
635637
macOS (CPU) or Linux+CUDA. Emits a complete `ExlaGoldenData` module:
636638
`elixir bench/exla_golden_gen.exs`.
637639

638-
### M14 — Serving concurrency cookbook + pooled-serving helper
640+
### M14 — Serving concurrency: stream-per-process + cookbook
639641

640642
`Emily.Compiler.__partitions_options__/1` raises on
641643
`max_concurrency > 1` — correct (Metal isn't safe for concurrent
@@ -644,36 +646,33 @@ single Emily-backed `Nx.Serving` cannot scale past one concurrent
644646
request. Production users will hit this in week one. M14 stops being
645647
silent about it and ships a tested pattern.
646648

647-
- **`Emily.Serving.start_pool/2`**: helper that starts N
648-
`Nx.Serving` instances behind a pool (poolboy or a hand-rolled
649-
Registry round-robin; pick the lighter dep). Each Serving runs in
650-
its own worker, dispatching requests in parallel across pool
651-
members rather than within one member.
652-
- **Documented pattern**: "for K concurrent inference requests, start
653-
K servings". Trade-off: each pool member loads its own weights —
654-
fine for small models, painful for Qwen3-7B+.
655-
- **Alternative for large models**: stream-per-process via MLX's
656-
`mx::scheduler::new_stream`. Expose `Native.set_default_stream/1`
657-
and `Emily.Stream.with_stream/2`; multi-process serving with one
658-
shared model + per-process MLX streams becomes the documented
659-
"big model" path. (Promotes streams from internal-only — see
649+
- **Stream-per-process**: the primary deliverable. Expose
650+
`Native.set_default_stream/1` and `Emily.Stream.with_stream/2`
651+
via MLX's `mx::scheduler::new_stream`. Each process gets its own
652+
Metal command queue; one shared model, per-process streams, no
653+
weight duplication. (Promotes streams from internal-only — see
660654
Project Decisions — to a narrowly-scoped public surface.)
655+
- **Cookbook: pooled servings**: documented pattern — "for K
656+
concurrent inference requests, start K `Nx.Serving` instances
657+
behind your own pool (poolboy, Registry round-robin, etc.)".
658+
No library code; clients bring their own pool since Emily already
659+
behaves correctly under that model. Trade-off: each pool member
660+
loads its own weights — fine for small models, painful for
661+
Qwen3-7B+.
661662
- **README + moduledoc updates**: surface the limitation and both
662663
patterns prominently. Today neither is mentioned outside a buried
663664
comment in `Emily.Compiler`.
664665

665666
**Testing**:
666-
- Pool helper test: K servings, K parallel requests, assert wall-clock
667-
is ~K× faster than serial.
668667
- Stream test: two processes, two streams, same model loaded once;
669668
no SIGSEGV under sustained parallel load
670669
(`test/soak/backend_concurrency_test.exs` documents the SIGSEGV
671670
story for the unstreamed case — this is the negative control).
672-
- `:serving_full` opt-in: end-to-end Qwen3 pool plus per-stream
673-
large-model pattern.
671+
- `:serving_full` opt-in: end-to-end per-stream large-model
672+
pattern.
674673

675-
**Exit:** both patterns documented; pool helper shipped; concurrency
676-
soak demonstrates the streamed path is stable.
674+
**Exit:** both patterns documented; concurrency soak demonstrates
675+
the streamed path is stable.
677676

678677
### M15 — Native linalg
679678

README.md

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,10 @@
22

33
Elixir bindings and Nx backend for Apple's [MLX](https://github.com/ml-explore/mlx).
44

5-
**Status: M7 — Bumblebee conformance breadth.** Backend (M2), Defn
6-
compiler (M5), and four Bumblebee models (DistilBERT, Qwen3, ViT,
7-
Whisper) run end-to-end. M8 (native `conv`) and M9 (1.0 release) are
8-
next. See [`PLAN.md`](PLAN.md) for the full roadmap and
9-
[`RELEASE.md`](RELEASE.md) for unreleased-version notes.
5+
**Status: M14 — Serving concurrency.** Stream-per-process
6+
concurrent inference via `Emily.Stream`. See [`PLAN.md`](PLAN.md) for
7+
the full roadmap and [`RELEASE.md`](RELEASE.md) for unreleased-version
8+
notes.
109

1110
## Why
1211

@@ -63,6 +62,36 @@ The low-level tensor API (`Emily.from_binary/3`, `to_binary/1`,
6362
`shape/1`, `dtype/1`, `eval/1`) remains available for diagnostics and
6463
direct MLX round-trips, but most users should go through Nx.
6564

65+
## Concurrency
66+
67+
MLX dispatches GPU work through Metal command queues. By default all
68+
ops share one queue (the default stream), which is not safe for
69+
concurrent dispatch from multiple OS threads.
70+
71+
**Stream-per-process** — for concurrent inference on a shared model:
72+
73+
```elixir
74+
stream = Emily.Stream.new(:gpu)
75+
76+
Emily.Stream.with_stream(stream, fn ->
77+
# All Emily ops here dispatch on this stream's command queue.
78+
model.(input)
79+
end)
80+
```
81+
82+
Each stream maps to its own Metal command queue. Multiple processes
83+
can run inference concurrently — one shared model, no weight
84+
duplication. Create streams at init time (one per serving process),
85+
not per-request.
86+
87+
**Pooled servings** — for simpler setups with small models, start K
88+
`Nx.Serving` instances behind a pool (poolboy, Registry, etc.). Each
89+
instance loads its own weights and runs on the default stream. No
90+
`Emily.Stream` needed. Trade-off: each pool member holds its own
91+
weight copy.
92+
93+
See `Emily.Stream` moduledoc for details.
94+
6695
## Milestones shipped
6796

6897
- **M0** — NIF scaffold, MLX prebuilt fetch, tensor round-trip.

RELEASE.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,24 @@
22

33
## Added
44

5+
- M14 — Serving concurrency: stream-per-process. `Emily.Stream` lets
6+
each BEAM process use its own Metal command queue for concurrent
7+
inference. `Emily.Stream.new/1` creates a stream,
8+
`Emily.Stream.with_stream/2` scopes all ops in a block to that
9+
stream, and `Emily.Stream.synchronize/1` waits for completion.
10+
The stream index is passed explicitly to every op NIF (no
11+
thread-local race) via a `-1` sentinel for "use default stream"
12+
(backwards-compatible). `Emily.Compiler.__partitions_options__/1`
13+
error message now points to `Emily.Stream`.
14+
- **New files**: `c_src/stream.cpp` (4 stream management NIFs),
15+
`lib/emily/stream.ex` (`Emily.Stream` struct + API),
16+
`test/emily/stream_test.exs`, `test/soak/stream_concurrency_test.exs`.
17+
- **Modified**: every op NIF gained a trailing `int64_t s` stream
18+
parameter; `Emily.Native` stubs, `Emily.Backend`, and all test
19+
files updated accordingly.
20+
- README now documents both concurrency patterns (stream-per-process
21+
and pooled servings).
22+
523
- M13 — EXLA gradient conformance. Adds a third gradient oracle —
624
EXLA (XLA CPU backend) — to catch bugs where Emily and BinaryBackend
725
agree on the wrong gradient (they share the same `Nx.Defn.grad`

c_src/emily/tensor.hpp

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,13 @@ unwrap_all(const std::vector<fine::ResourcePtr<Tensor>> &tensors) {
5959
return out;
6060
}
6161

62+
// 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.
65+
inline mx::Stream resolve_stream(int64_t stream_index) {
66+
if (stream_index < 0)
67+
return mx::default_stream(mx::default_device());
68+
return mx::get_stream(static_cast<int>(stream_index));
69+
}
70+
6271
} // namespace emily

c_src/emily_nif.cpp

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,9 @@ FINE_NIF(from_binary, 0);
7676
// cumulative reductions on interior axes of some 4-D shapes) raise
7777
// "Unable to safely factor shape" here; the Backend layer routes the
7878
// known cases around us.
79-
fine::Term to_binary(ErlNifEnv *env, fine::ResourcePtr<Tensor> tensor) {
80-
auto materialized = mx::contiguous(tensor->array);
79+
fine::Term to_binary(ErlNifEnv *env, fine::ResourcePtr<Tensor> tensor, int64_t s) {
80+
auto stream = emily::resolve_stream(s);
81+
auto materialized = mx::contiguous(tensor->array, false, stream);
8182
mx::eval(materialized);
8283

8384
// Defensive: mx::contiguous is supposed to give a row-contiguous

c_src/ops/binary.cpp

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ namespace {
1515
fine::ResourcePtr<Tensor> nif_name( \
1616
ErlNifEnv *, \
1717
fine::ResourcePtr<Tensor> a, \
18-
fine::ResourcePtr<Tensor> b) { \
19-
return wrap(mlx_fn(a->array, b->array)); \
18+
fine::ResourcePtr<Tensor> b, \
19+
int64_t s) { \
20+
return wrap(mlx_fn(a->array, b->array, emily::resolve_stream(s))); \
2021
} \
2122
FINE_NIF(nif_name, 0);
2223

c_src/ops/cast.cpp

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@ namespace {
1818
fine::ResourcePtr<Tensor> astype(
1919
ErlNifEnv *,
2020
fine::ResourcePtr<Tensor> a,
21-
std::tuple<fine::Atom, int64_t> dtype) {
22-
return wrap(mx::astype(a->array, to_mlx_dtype(dtype)));
21+
std::tuple<fine::Atom, int64_t> dtype,
22+
int64_t s) {
23+
return wrap(mx::astype(a->array, to_mlx_dtype(dtype),
24+
emily::resolve_stream(s)));
2325
}
2426
FINE_NIF(astype, 0);
2527

@@ -28,8 +30,10 @@ FINE_NIF(astype, 0);
2830
fine::ResourcePtr<Tensor> bitcast(
2931
ErlNifEnv *,
3032
fine::ResourcePtr<Tensor> a,
31-
std::tuple<fine::Atom, int64_t> dtype) {
32-
return wrap(mx::view(a->array, to_mlx_dtype(dtype)));
33+
std::tuple<fine::Atom, int64_t> dtype,
34+
int64_t s) {
35+
return wrap(mx::view(a->array, to_mlx_dtype(dtype),
36+
emily::resolve_stream(s)));
3337
}
3438
FINE_NIF(bitcast, 0);
3539

c_src/ops/conv.cpp

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,28 +19,29 @@ using emily::wrap;
1919

2020
namespace {
2121

22-
// `padding` bundles low/high padding into a single tuple so the NIF
23-
// arity stays manageable; both have length == spatial rank.
22+
// `padding` bundles low/high into a tuple; `dilation` bundles
23+
// kernel/input dilation — both keep the NIF arity manageable.
2424
fine::ResourcePtr<Tensor> conv_general(
2525
ErlNifEnv *,
2626
fine::ResourcePtr<Tensor> input,
2727
fine::ResourcePtr<Tensor> weight,
2828
std::vector<int64_t> stride,
2929
std::tuple<std::vector<int64_t>, std::vector<int64_t>> padding,
30-
std::vector<int64_t> kernel_dilation,
31-
std::vector<int64_t> input_dilation,
30+
std::tuple<std::vector<int64_t>, std::vector<int64_t>> dilation,
3231
int64_t groups,
33-
bool flip) {
32+
bool flip,
33+
int64_t s) {
3434
return wrap(mx::conv_general(
3535
input->array,
3636
weight->array,
3737
to_int_vec(stride),
3838
to_int_vec(std::get<0>(padding)),
3939
to_int_vec(std::get<1>(padding)),
40-
to_int_vec(kernel_dilation),
41-
to_int_vec(input_dilation),
40+
to_int_vec(std::get<0>(dilation)),
41+
to_int_vec(std::get<1>(dilation)),
4242
static_cast<int>(groups),
43-
flip));
43+
flip,
44+
emily::resolve_stream(s)));
4445
}
4546
FINE_NIF(conv_general, 0);
4647

c_src/ops/creation.cpp

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,20 @@ namespace {
2020
fine::ResourcePtr<Tensor> zeros(
2121
ErlNifEnv *,
2222
std::vector<int64_t> shape,
23-
std::tuple<fine::Atom, int64_t> dtype) {
24-
return wrap(mx::zeros(to_mlx_shape(shape), to_mlx_dtype(dtype)));
23+
std::tuple<fine::Atom, int64_t> dtype,
24+
int64_t s) {
25+
return wrap(mx::zeros(to_mlx_shape(shape), to_mlx_dtype(dtype),
26+
emily::resolve_stream(s)));
2527
}
2628
FINE_NIF(zeros, 0);
2729

2830
fine::ResourcePtr<Tensor> ones(
2931
ErlNifEnv *,
3032
std::vector<int64_t> shape,
31-
std::tuple<fine::Atom, int64_t> dtype) {
32-
return wrap(mx::ones(to_mlx_shape(shape), to_mlx_dtype(dtype)));
33+
std::tuple<fine::Atom, int64_t> dtype,
34+
int64_t s) {
35+
return wrap(mx::ones(to_mlx_shape(shape), to_mlx_dtype(dtype),
36+
emily::resolve_stream(s)));
3337
}
3438
FINE_NIF(ones, 0);
3539

@@ -39,8 +43,10 @@ fine::ResourcePtr<Tensor> full(
3943
ErlNifEnv *,
4044
std::vector<int64_t> shape,
4145
fine::ResourcePtr<Tensor> value,
42-
std::tuple<fine::Atom, int64_t> dtype) {
43-
return wrap(mx::full(to_mlx_shape(shape), value->array, to_mlx_dtype(dtype)));
46+
std::tuple<fine::Atom, int64_t> dtype,
47+
int64_t s) {
48+
return wrap(mx::full(to_mlx_shape(shape), value->array, to_mlx_dtype(dtype),
49+
emily::resolve_stream(s)));
4450
}
4551
FINE_NIF(full, 0);
4652

@@ -51,8 +57,10 @@ fine::ResourcePtr<Tensor> arange(
5157
double start,
5258
double stop,
5359
double step,
54-
std::tuple<fine::Atom, int64_t> dtype) {
55-
return wrap(mx::arange(start, stop, step, to_mlx_dtype(dtype)));
60+
std::tuple<fine::Atom, int64_t> dtype,
61+
int64_t s) {
62+
return wrap(mx::arange(start, stop, step, to_mlx_dtype(dtype),
63+
emily::resolve_stream(s)));
5664
}
5765
FINE_NIF(arange, 0);
5866

@@ -62,11 +70,13 @@ fine::ResourcePtr<Tensor> eye(
6270
int64_t n,
6371
int64_t m,
6472
int64_t k,
65-
std::tuple<fine::Atom, int64_t> dtype) {
73+
std::tuple<fine::Atom, int64_t> dtype,
74+
int64_t s) {
6675
return wrap(mx::eye(static_cast<int>(n),
6776
static_cast<int>(m),
6877
static_cast<int>(k),
69-
to_mlx_dtype(dtype)));
78+
to_mlx_dtype(dtype),
79+
emily::resolve_stream(s)));
7080
}
7181
FINE_NIF(eye, 0);
7282

c_src/ops/fast.cpp

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,11 @@ fine::ResourcePtr<Tensor> fast_rms_norm(
5252
ErlNifEnv *,
5353
fine::ResourcePtr<Tensor> x,
5454
std::optional<fine::ResourcePtr<Tensor>> weight,
55-
double eps) {
55+
double eps,
56+
int64_t s) {
5657
return wrap(mx::fast::rms_norm(
57-
x->array, opt_array(weight), static_cast<float>(eps)));
58+
x->array, opt_array(weight), static_cast<float>(eps),
59+
emily::resolve_stream(s)));
5860
}
5961
FINE_NIF(fast_rms_norm, 0);
6062

@@ -71,12 +73,14 @@ fine::ResourcePtr<Tensor> fast_layer_norm(
7173
fine::ResourcePtr<Tensor> x,
7274
std::optional<fine::ResourcePtr<Tensor>> weight,
7375
std::optional<fine::ResourcePtr<Tensor>> bias,
74-
double eps) {
76+
double eps,
77+
int64_t s) {
7578
return wrap(mx::fast::layer_norm(
7679
x->array,
7780
opt_array(weight),
7881
opt_array(bias),
79-
static_cast<float>(eps)));
82+
static_cast<float>(eps),
83+
emily::resolve_stream(s)));
8084
}
8185
FINE_NIF(fast_layer_norm, 0);
8286

@@ -108,7 +112,8 @@ fine::ResourcePtr<Tensor> fast_rope(
108112
std::optional<double> base,
109113
double scale,
110114
fine::ResourcePtr<Tensor> offset,
111-
std::optional<fine::ResourcePtr<Tensor>> freqs) {
115+
std::optional<fine::ResourcePtr<Tensor>> freqs,
116+
int64_t s) {
112117
std::optional<float> base_f;
113118
if (base) base_f = static_cast<float>(*base);
114119

@@ -119,7 +124,8 @@ fine::ResourcePtr<Tensor> fast_rope(
119124
base_f,
120125
static_cast<float>(scale),
121126
offset->array,
122-
opt_array(freqs)));
127+
opt_array(freqs),
128+
emily::resolve_stream(s)));
123129
}
124130
FINE_NIF(fast_rope, 0);
125131

@@ -147,14 +153,16 @@ fine::ResourcePtr<Tensor> fast_scaled_dot_product_attention(
147153
fine::ResourcePtr<Tensor> v,
148154
double scale,
149155
std::string mask_mode,
150-
std::vector<fine::ResourcePtr<Tensor>> mask_arrs) {
156+
std::vector<fine::ResourcePtr<Tensor>> mask_arrs,
157+
int64_t s) {
151158
return wrap(mx::fast::scaled_dot_product_attention(
152159
q->array,
153160
k->array,
154161
v->array,
155162
static_cast<float>(scale),
156163
mask_mode,
157-
unwrap_all(mask_arrs)));
164+
unwrap_all(mask_arrs),
165+
emily::resolve_stream(s)));
158166
}
159167
FINE_NIF(fast_scaled_dot_product_attention, 0);
160168

0 commit comments

Comments
 (0)