Skip to content

Commit 998c117

Browse files
authored
Merge pull request #145 from ausimian/feat/async-eval
feat: async_eval and integer-offset RoPE for non-blocking decode
2 parents d135052 + 924f541 commit 998c117

8 files changed

Lines changed: 260 additions & 7 deletions

File tree

RELEASE.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
### Added
2+
3+
- `Emily.async_eval/1` (and `Emily.Native.async_eval/2`) schedule evaluation of
4+
one or more lazy graphs **without blocking on the GPU**, wrapping
5+
`mlx::core::async_eval`. The work is handed to the device's command queue and
6+
the call returns as soon as it is enqueued — not when it finishes. This lets a
7+
caller keep dispatching the next step's ops while the device computes the
8+
current one (e.g. an autoregressive decode loop), blocking only when a value
9+
is actually read back on the host via `to_binary/1` / `eval/1`. Pass every
10+
output of a step (logits plus all KV-cache buffers) in one call.
11+
- `Emily.Native.fast_rope_int/8` — RoPE with an **integer** absolute-position
12+
`offset` (routing to MLX's int-offset `rope` overload), for incremental decode
13+
where the caller tracks position host-side. Complements the existing
14+
tensor-offset `fast_rope/8`. Note: feed the kernel the 4-D
15+
`{batch, heads, seq, head_dim}` layout — in 3-D, MLX 0.31 mis-rotates
16+
single-token (`seq == 1`) inputs.

bench/native/compile_microbench.cpp

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ namespace mx = mlx::core;
3939
// 1000 iterations is fast to run yet long enough that fusion matters.
4040
constexpr int kBatch = 1;
4141
static int kSeq = 128; // overridable via --seq
42+
static int kLayers = 1; // overridable via --layers (stack the block)
4243
constexpr int kHidden = 1024;
4344
constexpr int kHeads = 16;
4445
constexpr int kHeadDim = 64; // kHeads * kHeadDim = 1024 = kHidden
@@ -129,6 +130,18 @@ static std::vector<mx::array> block(const std::vector<mx::array>& in) {
129130
return {out};
130131
}
131132

133+
// Stack the block kLayers times (reusing weights — fine for timing). This
134+
// reaches forward-scale op counts (~15 ops/block × 48 ≈ 720, matching the
135+
// Gemma decode forward's ~750) so dispatch (build+encode) is large enough to
136+
// dominate, isolating whether mx::compile amortizes it.
137+
static std::vector<mx::array> multi_block(const std::vector<mx::array>& in) {
138+
std::vector<mx::array> cur = in;
139+
for (int l = 0; l < kLayers; ++l) {
140+
cur[0] = block(cur)[0];
141+
}
142+
return {cur[0]};
143+
}
144+
132145
// ---------------------------------------------------------------------
133146

134147
struct Stats {
@@ -200,20 +213,48 @@ static Stats time_runs(Fn&& fn, int warmup, int iters) {
200213
return summarise(samples);
201214
}
202215

216+
// Dispatch-only timing: time [build graph + async_eval] WITHOUT waiting for
217+
// the GPU (synchronize happens outside the timed region). This isolates the
218+
// host-side dispatch cost (graph build + MLX encode/schedule) — the ~68 ms/tok
219+
// that dominates Gemma decode — from GPU compute. The question: does compile
220+
// shrink THIS, not the GPU.
221+
template <typename Fn>
222+
static Stats time_dispatch(Fn&& fn, int warmup, int iters) {
223+
for (int i = 0; i < warmup; ++i) {
224+
auto out = fn();
225+
mx::async_eval(out);
226+
mx::synchronize();
227+
}
228+
229+
std::vector<double> samples;
230+
samples.reserve(iters);
231+
232+
for (int i = 0; i < iters; ++i) {
233+
auto start = std::chrono::high_resolution_clock::now();
234+
auto out = fn();
235+
mx::async_eval(out); // schedule; returns after encode, before GPU completes
236+
auto end = std::chrono::high_resolution_clock::now();
237+
mx::synchronize(); // drain GPU OUTSIDE the timed region
238+
samples.push_back(std::chrono::duration<double, std::milli>(end - start).count());
239+
}
240+
241+
return summarise(samples);
242+
}
243+
203244
static void run_on_device(mx::Device::DeviceType dev_type, const char* label,
204245
int warmup, int iters) {
205246
mx::set_default_device(mx::Device(dev_type));
206247

207248
auto inputs = make_inputs();
208249

209-
// Uncompiled baseline: call block() directly on each iteration.
250+
// Uncompiled baseline: rebuild the (kLayers-stacked) graph each iteration.
210251
auto uncompiled_fn = [&inputs]() -> std::vector<mx::array> {
211-
return block(inputs);
252+
return multi_block(inputs);
212253
};
213254

214-
// Compiled: wrap block in mx::compile. First call of the returned
215-
// closure does the trace; subsequent calls hit the fused tape.
216-
auto compiled_closure = mx::compile(block, /*shapeless=*/false);
255+
// Compiled: wrap in mx::compile. First call traces; subsequent calls hit the
256+
// cached tape (skipping the graph build/simplify).
257+
auto compiled_closure = mx::compile(multi_block, /*shapeless=*/false);
217258
auto compiled_fn = [&compiled_closure, &inputs]() -> std::vector<mx::array> {
218259
return compiled_closure(inputs);
219260
};
@@ -236,8 +277,17 @@ static void run_on_device(mx::Device::DeviceType dev_type, const char* label,
236277
std::printf(" speedup (median) = %.3fx (min) = %.3fx\n",
237278
median_speedup, min_speedup);
238279

239-
bool passes = median_speedup >= 1.20;
240-
std::printf(" gate (>=1.20x on median): %s\n", passes ? "PASS" : "FAIL");
280+
// Dispatch-only: the host-side build+encode cost (no GPU wait) — the thing
281+
// that dominates Gemma decode. Does compile amortize it?
282+
auto unc_disp = time_dispatch(uncompiled_fn, warmup, iters);
283+
auto cmp_disp = time_dispatch(compiled_fn, warmup, iters);
284+
std::printf(" [dispatch-only, no GPU wait]\n");
285+
std::printf(" uncompiled min=%.3f ms median=%.3f ms\n",
286+
unc_disp.min_ms, unc_disp.median_ms);
287+
std::printf(" compiled min=%.3f ms median=%.3f ms\n",
288+
cmp_disp.min_ms, cmp_disp.median_ms);
289+
std::printf(" dispatch speedup (median) = %.3fx\n",
290+
unc_disp.median_ms / cmp_disp.median_ms);
241291
}
242292

243293
// ---------------------------------------------------------------------
@@ -310,6 +360,8 @@ int main(int argc, char** argv) {
310360
iters = std::atoi(argv[++i]);
311361
} else if (std::strcmp(argv[i], "--seq") == 0 && i + 1 < argc) {
312362
kSeq = std::atoi(argv[++i]);
363+
} else if (std::strcmp(argv[i], "--layers") == 0 && i + 1 < argc) {
364+
kLayers = std::atoi(argv[++i]);
313365
}
314366
}
315367

c_src/emily_nif.cpp

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,4 +144,26 @@ fine::Term eval_nif(ErlNifEnv *env,
144144
}
145145
FINE_NIF(eval_nif, 0);
146146

147+
// async_eval_nif/2 — schedule evaluation of the lazy graphs rooted at
148+
// these tensors *without* blocking on the GPU. Unlike eval_nif (which
149+
// calls mx::eval and waits for the GPU before replying), this calls
150+
// mx::async_eval: the worker hands the graphs to the Metal command queue
151+
// and the NIF replies {ref, {:ok, :ok}} as soon as the work is *queued*,
152+
// not finished. Callers drive an autoregressive loop by scheduling the
153+
// next step's outputs here and only blocking (via to_binary/eval) on the
154+
// value they actually need on the host — so dispatch of step N+1 overlaps
155+
// the GPU compute of step N. Takes a list so one call schedules a step's
156+
// logits + every KV-cache buffer together.
157+
fine::Term async_eval_nif(ErlNifEnv *env,
158+
fine::ResourcePtr<WorkerThread> w,
159+
std::vector<fine::ResourcePtr<Tensor>> tensors) {
160+
return emily::async_reply(
161+
env, w,
162+
[tensors = std::move(tensors)](mx::Stream &, ErlNifEnv *msg_env) {
163+
mx::async_eval(emily::unwrap_all(tensors));
164+
return fine::encode(msg_env, emily::atoms::ok);
165+
});
166+
}
167+
FINE_NIF(async_eval_nif, 0);
168+
147169
FINE_INIT("Elixir.Emily.Native");

c_src/ops/fast.cpp

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,33 @@ fine::Term fast_rope_nif(
8484
}
8585
FINE_NIF(fast_rope_nif, 0);
8686

87+
// Int-offset variant. `offset` is a plain integer absolute position (the
88+
// caller tracks it host-side). Uses MLX's int-offset rope overload, which is
89+
// correct for single-token (seq == 1) inputs — the array-offset overload
90+
// (fast_rope_nif) mis-rotates seq == 1, breaking incremental decode. `base`
91+
// is nullopt when `freqs` is supplied.
92+
fine::Term fast_rope_int_nif(
93+
ErlNifEnv *env,
94+
fine::ResourcePtr<WorkerThread> w,
95+
fine::ResourcePtr<Tensor> x,
96+
int64_t dims,
97+
bool traditional,
98+
std::optional<double> base,
99+
double scale,
100+
int64_t offset,
101+
std::optional<fine::ResourcePtr<Tensor>> freqs) {
102+
return async_encoded(env, w,
103+
[x = std::move(x), dims, traditional, base, scale, offset,
104+
freqs = std::move(freqs)](mx::Stream &s) {
105+
std::optional<float> base_f;
106+
if (base) base_f = static_cast<float>(*base);
107+
return wrap(mx::fast::rope(x->array, emily::checked_int(dims, "dims"), traditional,
108+
base_f, static_cast<float>(scale),
109+
emily::checked_int(offset, "offset"), opt_array(freqs), s));
110+
});
111+
}
112+
FINE_NIF(fast_rope_int_nif, 0);
113+
87114
fine::Term fast_scaled_dot_product_attention_nif(
88115
ErlNifEnv *env,
89116
fine::ResourcePtr<WorkerThread> w,

lib/emily.ex

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,4 +187,29 @@ defmodule Emily do
187187
{Native.eval(Emily.MlxStream.default_worker(), tensor), %{}}
188188
end)
189189
end
190+
191+
@doc """
192+
Schedule evaluation of `tensors` *without* blocking on the GPU.
193+
194+
Hands the lazy graphs to the device's command queue and returns as soon
195+
as the work is enqueued — not when it finishes (contrast `eval/1`, which
196+
waits). This lets a caller keep dispatching the next step's ops while the
197+
GPU computes the current one; block only when you actually read a value
198+
on the host (`to_binary/1` / `eval/1`). Pass every output of a step
199+
(e.g. logits plus all KV-cache buffers) in one call.
200+
201+
## Examples
202+
203+
iex> a = Nx.tensor([1.0, 2.0, 3.0], backend: Emily.Backend)
204+
iex> b = a |> Nx.multiply(2.0) |> Nx.add(1.0)
205+
iex> Emily.async_eval([b.data.ref])
206+
:ok
207+
208+
"""
209+
@spec async_eval([t()]) :: :ok
210+
def async_eval(tensors) when is_list(tensors) do
211+
:telemetry.span([:emily, :async_eval], %{}, fn ->
212+
{Native.async_eval(Emily.MlxStream.default_worker(), tensors), %{}}
213+
end)
214+
end
190215
end

lib/emily/native.ex

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,16 @@ defmodule Emily.Native do
8383
@spec eval(worker(), tensor()) :: :ok
8484
def eval(w, tensor), do: await(eval_nif(w, tensor))
8585

86+
@doc false
87+
@spec async_eval_nif(worker(), [tensor()]) :: reference()
88+
def async_eval_nif(_w, _tensors), do: nif()
89+
90+
# Schedule (non-blocking) evaluation of several lazy graphs at once: the
91+
# GPU work is queued and this returns as soon as it's enqueued, not when
92+
# it finishes. See `async_eval_nif` in c_src/emily_nif.cpp.
93+
@spec async_eval(worker(), [tensor()]) :: :ok
94+
def async_eval(w, tensors), do: await(async_eval_nif(w, tensors))
95+
8696
# --- Worker ------------------------------------------------------
8797

8898
# Default per-worker queue depth. Each op is awaited synchronously, so
@@ -771,6 +781,45 @@ defmodule Emily.Native do
771781
)
772782
)
773783

784+
@doc false
785+
@spec fast_rope_int_nif(
786+
worker(),
787+
tensor(),
788+
integer(),
789+
boolean(),
790+
float() | nil,
791+
float(),
792+
integer(),
793+
tensor() | nil
794+
) :: reference()
795+
def fast_rope_int_nif(_w, _x, _dims, _traditional, _base, _scale, _offset, _freqs), do: nif()
796+
797+
# Like `fast_rope/8` but `offset` is a plain integer (absolute position),
798+
# routing to MLX's int-offset rope overload. Correct for single-token decode
799+
# (seq == 1), where the tensor-offset `fast_rope/8` mis-rotates.
800+
@spec fast_rope_int(
801+
worker(),
802+
tensor(),
803+
integer(),
804+
boolean(),
805+
float() | nil,
806+
float(),
807+
integer(),
808+
tensor() | nil
809+
) :: tensor()
810+
def fast_rope_int(w, x, dims, traditional, base, scale, offset, freqs),
811+
do:
812+
await(
813+
fast_rope_int_nif(w, x, dims, traditional, base, scale, offset, freqs),
814+
native_context(:fast_rope_int, w, [x: x, freqs: freqs],
815+
dims: dims,
816+
traditional: traditional,
817+
base: base,
818+
scale: scale,
819+
offset: offset
820+
)
821+
)
822+
774823
@doc false
775824
@spec fast_scaled_dot_product_attention_nif(
776825
worker(),

test/emily/fast/rope_test.exs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,4 +127,48 @@ defmodule Emily.Fast.RoPETest do
127127
assert_close(fused, expected, tol: @f32_tol)
128128
end
129129
end
130+
131+
describe "fast_rope_int/8 (integer offset, incremental decode)" do
132+
# Incremental decode ropes one token at a time at an integer absolute
133+
# position. It must be correct for seq == 1 — fed the 4D
134+
# {1, heads, seq, head_dim} layout (in 3D, MLX 0.31 mis-rotates seq == 1).
135+
# This pins the decode-vs-prefill consistency a generation loop relies on:
136+
# a single token roped at offset k must equal position k of the full
137+
# sequence roped at offset 0.
138+
test "single-token rope at offset k equals position k of a full-sequence rope" do
139+
dims = 32
140+
heads = 2
141+
seq = 8
142+
k = 5
143+
w = Emily.MlxStream.default_worker()
144+
145+
freqs =
146+
Nx.iota({div(dims, 2)}, type: :f32)
147+
|> Nx.multiply(2.0)
148+
|> Nx.divide(dims)
149+
|> then(&Nx.pow(10_000.0, &1))
150+
151+
x = Nx.iota({1, heads, seq, dims}, type: :f32) |> Nx.divide(100)
152+
153+
wrap = fn r, shape ->
154+
%Nx.Tensor{
155+
data: %Emily.Backend{ref: r},
156+
shape: shape,
157+
type: Emily.Native.dtype(r),
158+
names: List.duplicate(nil, tuple_size(shape))
159+
}
160+
end
161+
162+
rope_int = fn t, offset, shape ->
163+
Emily.Native.fast_rope_int(w, t.data.ref, dims, false, nil, 1.0, offset, freqs.data.ref)
164+
|> wrap.(shape)
165+
end
166+
167+
full = rope_int.(x, 0, {1, heads, seq, dims})
168+
tok = Nx.slice(x, [0, 0, k, 0], [1, heads, 1, dims])
169+
single = rope_int.(tok, k, {1, heads, 1, dims})
170+
171+
assert_close(single, Nx.slice(full, [0, 0, k, 0], [1, heads, 1, dims]), tol: @f32_tol)
172+
end
173+
end
130174
end

test/emily_test.exs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,24 @@ defmodule EmilyTest do
8787
assert Emily.to_binary(t) == bin
8888
end
8989

90+
test "async_eval/1 schedules without blocking and yields the same values as eager" do
91+
a = Nx.tensor([1.0, 2.0, 3.0, 4.0], backend: Emily.Backend)
92+
b = a |> Nx.multiply(2.0) |> Nx.add(1.0)
93+
c = Nx.subtract(b, a)
94+
95+
# Non-blocking schedule of several outputs at once returns :ok.
96+
assert :ok = Emily.async_eval([b.data.ref, c.data.ref])
97+
98+
# The later host read (to_binary, via Nx) sees the awaited values —
99+
# identical to the eager path, only the eval timing differs.
100+
assert Nx.to_flat_list(b) == [3.0, 5.0, 7.0, 9.0]
101+
assert Nx.to_flat_list(c) == [2.0, 3.0, 4.0, 5.0]
102+
end
103+
104+
test "async_eval/1 of an empty list is a no-op" do
105+
assert :ok = Emily.async_eval([])
106+
end
107+
90108
test "tensor survives GC as long as a ref is held" do
91109
bin = <<42.0::float-32-native>>
92110
t = Emily.from_binary(bin, [1], {:f, 32})

0 commit comments

Comments
 (0)