Skip to content

Commit fb386ec

Browse files
authored
feat: add fused kv_cache+sdpa (#124)
* feat: metal capture * 200 tok/s on best case native * format * feat: break the 300 tok/s barrier
1 parent 695a92a commit fb386ec

11 files changed

Lines changed: 1041 additions & 43 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,6 @@ emlx-*.tar
2929
# Validation subproject build artifacts (not the mix.lock — that is committed).
3030
/**/_build/
3131
/**/deps/
32+
33+
# Local working notes / investigation plans, not part of the public plan history.
34+
/.work/

emlx/c_src/emlx_compiler.cpp

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1468,8 +1468,15 @@ static const std::unordered_map<std::string, OpFn> op_registry = {
14681468
{}, -std::numeric_limits<float>::infinity(), mask_dtype);
14691469
auto additive = mlx::core::where(keep, zero_val, neginf_val);
14701470

1471-
return mlx::core::fast::scaled_dot_product_attention(
1471+
auto attn_t = mlx::core::fast::scaled_dot_product_attention(
14721472
q, k, v, scale, "array", additive, std::nullopt);
1473+
1474+
// Replace NaN with 0 for all-masked rows (softmax(-inf,...,-inf) = NaN
1475+
// in Flash-Attention when seq_len >= Metal tile size, but semantically
1476+
// = 0) -- happens for left-padded prefill query rows that have no
1477+
// causally-visible *and* key_mask-valid key at all.
1478+
return mlx::core::where(mlx::core::isnan(attn_t),
1479+
mlx::core::zeros_like(attn_t), attn_t);
14731480
}},
14741481

14751482
// sdpa (causal + key_mask, + sinks): operands = [q, k, v, key_mask,
@@ -1504,8 +1511,12 @@ static const std::unordered_map<std::string, OpFn> op_registry = {
15041511
{}, -std::numeric_limits<float>::infinity(), mask_dtype);
15051512
auto additive = mlx::core::where(keep, zero_val, neginf_val);
15061513

1507-
return mlx::core::fast::scaled_dot_product_attention(
1514+
auto attn_t = mlx::core::fast::scaled_dot_product_attention(
15081515
q, k, v, scale, "array", additive, sinks);
1516+
1517+
// See "fast_sdpa_causal_key_masked" above: NaN-guard all-masked rows.
1518+
return mlx::core::where(mlx::core::isnan(attn_t),
1519+
mlx::core::zeros_like(attn_t), attn_t);
15091520
}},
15101521

15111522
// rope (scalar offset): operands = [a];
@@ -1939,6 +1950,78 @@ static const std::unordered_map<std::string, MultiOpFn> multi_op_registry = {
19391950
[](const auto &ops, const auto &) -> std::vector<mlx::core::array> {
19401951
return contiguous_all(mlx::core::linalg::lu(ops[0], k_linalg_cpu));
19411952
}},
1953+
// The head-transpose needed for `fast::scaled_dot_product_attention`
1954+
// (which requires {B, N, T, D}) is done internally on q and on the
1955+
// *already-written* k_upd/v_upd -- this is the same single full-cache
1956+
// transpose the unfused path already pays for (its own
1957+
// `build_sdpa_layer` transposes the post-update K/V cache before
1958+
// calling SDPA); fusing does not add a second one, since k_upd/v_upd
1959+
// are returned pre-transpose.
1960+
{"kv_cache_sdpa_update",
1961+
[](const auto &ops, const auto &attrs) -> std::vector<mlx::core::array> {
1962+
const auto &q = ops[0];
1963+
const auto &new_k = ops[1];
1964+
const auto &new_v = ops[2];
1965+
const auto &k_cache = ops[3];
1966+
const auto &v_cache = ops[4];
1967+
const auto &offset = ops[5];
1968+
const auto &key_mask = ops[6];
1969+
float scale = attr_to_float(attrs[0]);
1970+
int kv_offset = static_cast<int>(attrs[1]);
1971+
1972+
int T_max = static_cast<int>(k_cache.shape(1));
1973+
int T_q_write = static_cast<int>(new_k.shape(1));
1974+
1975+
// Dynamic write-start {0, clamp(offset, 0, T_max-T_q_write), 0, 0} --
1976+
// same clamp-and-concatenate construction as op_registry["put_slice"]
1977+
// (T_max-T_q_write, not T_max-1, so the write itself never runs past
1978+
// the cache's last valid row when T_q_write > 1, e.g. prefill).
1979+
auto s = mlx::core::reshape(mlx::core::astype(offset, mlx::core::int32), {1});
1980+
auto max_s = mlx::core::full({1}, T_max - T_q_write, mlx::core::int32);
1981+
auto zero1 = mlx::core::zeros({1}, mlx::core::int32);
1982+
auto offset_clamped = mlx::core::minimum(mlx::core::maximum(s, zero1), max_s);
1983+
auto start = mlx::core::concatenate({zero1, offset_clamped, zero1, zero1}, 0);
1984+
1985+
std::vector<int> all_axes = {0, 1, 2, 3};
1986+
auto k_upd = mlx::core::slice_update(k_cache, new_k, start, all_axes);
1987+
auto v_upd = mlx::core::slice_update(v_cache, new_v, start, all_axes);
1988+
1989+
// Head-transpose for SDPA: {B, T, N, D} -> {B, N, T, D}. Mirrors
1990+
// build_sdpa_layer's own Nx.transpose(axes: [0, 2, 1, 3]) exactly.
1991+
std::vector<int> head_transpose = {0, 2, 1, 3};
1992+
auto q_t = mlx::core::transpose(q, head_transpose);
1993+
auto k_t = mlx::core::transpose(k_upd, head_transpose);
1994+
auto v_t = mlx::core::transpose(v_upd, head_transpose);
1995+
1996+
// Combined causal + key_mask additive mask -- verbatim copy of
1997+
// "fast_sdpa_causal_key_masked"'s formula above (T_kv = T_max here).
1998+
auto km = mlx::core::reshape(key_mask, {key_mask.shape(0), 1, 1, key_mask.shape(1)});
1999+
int T_q = q_t.shape(2);
2000+
auto row = mlx::core::reshape(mlx::core::arange(T_q, mlx::core::int32), {1, 1, T_q, 1});
2001+
auto col = mlx::core::reshape(mlx::core::arange(T_max, mlx::core::int32), {1, 1, 1, T_max});
2002+
auto causal_bool = mlx::core::less_equal(
2003+
col, mlx::core::add(row, mlx::core::array(kv_offset, mlx::core::int32)));
2004+
auto keep = mlx::core::logical_and(km, causal_bool);
2005+
auto mask_dtype = q_t.dtype();
2006+
auto zero_val = mlx::core::zeros({}, mask_dtype);
2007+
auto neginf_val =
2008+
mlx::core::full({}, -std::numeric_limits<float>::infinity(), mask_dtype);
2009+
auto additive = mlx::core::where(keep, zero_val, neginf_val);
2010+
2011+
auto attn_out = mlx::core::fast::scaled_dot_product_attention(
2012+
q_t, k_t, v_t, scale, "array", additive, std::nullopt);
2013+
2014+
// Replace NaN with 0 for all-masked rows (softmax(-inf,...,-inf) = NaN
2015+
// in Flash-Attention when seq_len >= Metal tile size, but semantically
2016+
// = 0) -- happens for left-padded prefill query rows that have no
2017+
// causally-visible *and* key_mask-valid key at all. See
2018+
// "fast_sdpa_causal_key_masked" above for the unfused version of this
2019+
// same guard.
2020+
attn_out = mlx::core::where(mlx::core::isnan(attn_out),
2021+
mlx::core::zeros_like(attn_out), attn_out);
2022+
2023+
return {attn_out, k_upd, v_upd};
2024+
}},
19422025
};
19432026

19442027
// ── interpret_instructions ────────────────────────────────────────────────

emlx/c_src/emlx_fast.cpp

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,8 +339,14 @@ mlx::core::array fast_sdpa_causal_key_masked_impl(
339339
auto neginf_val = full({}, -std::numeric_limits<float>::infinity(), mask_dtype);
340340
auto additive = where(keep, zero_val, neginf_val);
341341

342-
return fast::scaled_dot_product_attention(
342+
auto attn_t = fast::scaled_dot_product_attention(
343343
*q, *k, *v, (float)scale, "array", additive, sinks_opt, device);
344+
345+
// Replace NaN with 0 for all-masked rows (softmax(-inf,...,-inf) = NaN in
346+
// Flash-Attention when seq_len >= Metal tile size, but semantically = 0).
347+
// This happens for left-padded prefill rows, where a padding query
348+
// position has no causally-visible *and* key_mask-valid keys at all.
349+
return where(isnan(attn_t), zeros_like(attn_t, device), attn_t, device);
344350
}
345351
}
346352
FINE_ASYNC_NIF(fast_sdpa_causal_key_masked)

emlx/c_src/emlx_nif.cpp

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1335,6 +1335,33 @@ NIF(set_cache_limit) {
13351335
return nx::nif::ok(env, enif_make_uint64(env, prev));
13361336
}
13371337

1338+
// Starts a Metal GPU frame capture, writing the trace to `path` (a
1339+
// `.gputrace` bundle). Requires `MTL_CAPTURE_ENABLED=1` in the process
1340+
// environment *before* the BEAM (and therefore the Metal device) started —
1341+
// Apple's capture gate is read once at process startup and cannot be
1342+
// enabled lazily. On MLX 0.31.2, missing this precondition throws (caught
1343+
// by CATCH() below and surfaced as a normal EMLX.NIFError), not a silent
1344+
// no-op. See EMLX.metal_start_capture/1's moduledoc for details.
1345+
NIF(metal_start_capture) {
1346+
std::string path;
1347+
if (!nx::nif::get(env, argv[0], path)) {
1348+
return nx::nif::error(env, "Unable to get path param.");
1349+
}
1350+
try {
1351+
mlx::core::metal::start_capture(path);
1352+
return nx::nif::ok(env);
1353+
}
1354+
CATCH()
1355+
}
1356+
1357+
NIF(metal_stop_capture) {
1358+
try {
1359+
mlx::core::metal::stop_capture();
1360+
return nx::nif::ok(env);
1361+
}
1362+
CATCH()
1363+
}
1364+
13381365
NIF(strides) {
13391366
TENSOR_PARAM(0, t);
13401367

@@ -1828,8 +1855,12 @@ NIF(eval_program) { return emlx::native::eval_program(env, argc, argv); }
18281855
ASYNC_NIF(eval_program)
18291856

18301857
static ErlNifFunc nif_funcs[] = {
1831-
{"eval", 2, eval_async, ERL_NIF_DIRTY_JOB_IO_BOUND},
1832-
{"eval_many", 2, eval_many_async, ERL_NIF_DIRTY_JOB_IO_BOUND},
1858+
// No dirty-scheduler flag: eval/eval_many post to a dedicated Worker
1859+
// OS thread via async_dispatch (emlx_async.hpp) and return immediately —
1860+
// the calling BEAM scheduler never blocks on the actual MLX work, same
1861+
// as the ~150 sibling ASYNC_NIF ops below (item 3.8 fold-in).
1862+
{"eval", 2, eval_async},
1863+
{"eval_many", 2, eval_many_async},
18331864
{"to_device", 3, to_device_async},
18341865
{"to_blob", 2, to_blob_async},
18351866
{"to_blob", 3, to_blob_async},
@@ -1979,6 +2010,8 @@ static ErlNifFunc nif_funcs[] = {
19792010
{"reset_peak_memory", 0, reset_peak_memory},
19802011
{"set_memory_limit", 1, set_memory_limit},
19812012
{"set_cache_limit", 1, set_cache_limit},
2013+
{"metal_start_capture", 1, metal_start_capture},
2014+
{"metal_stop_capture", 0, metal_stop_capture},
19822015

19832016
// ── Worker control NIFs.
19842017
{"command_queue_new", 1, command_queue_new},
@@ -2006,9 +2039,11 @@ static ErlNifFunc nif_funcs[] = {
20062039
{"kv_cache_sdpa_update", 9, kv_cache_sdpa_update_async},
20072040

20082041
// ── Native compiler NIFs.
2009-
{"compile_program", 2, emlx::native::compile_program_async,
2010-
ERL_NIF_DIRTY_JOB_CPU_BOUND},
2011-
{"eval_program", 3, eval_program_async, ERL_NIF_DIRTY_JOB_IO_BOUND},
2042+
// No dirty-scheduler flag on either: both are FINE_ASYNC_NIF/ASYNC_NIF-
2043+
// wrapped (post to a Worker OS thread, return immediately) — same
2044+
// reasoning as eval/eval_many above (item 3.8 fold-in).
2045+
{"compile_program", 2, emlx::native::compile_program_async},
2046+
{"eval_program", 3, eval_program_async},
20122047
// resolve_runtime_call is NOT worker-routed: it only decodes a reply and
20132048
// memcpy's it into pre-registered buffers/notifies a condvar — no MLX
20142049
// graph work, so it can run directly on the calling BEAM scheduler.

emlx/lib/emlx.ex

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2406,4 +2406,38 @@ defmodule EMLX do
24062406
def set_cache_limit(limit) when is_integer(limit) and limit >= 0 do
24072407
EMLX.NIF.set_cache_limit(limit) |> unwrap!()
24082408
end
2409+
2410+
@doc """
2411+
Starts a Metal GPU frame capture, writing the trace to `path` (a
2412+
`.gputrace` bundle openable in Xcode's GPU Debugger via File → Open).
2413+
2414+
Requires `MTL_CAPTURE_ENABLED=1` to be set in the process environment
2415+
*before* the BEAM starts — Apple's Metal framework reads this gate once at
2416+
process startup (macOS 14+), and setting it later (e.g. from within
2417+
Elixir) has no effect. On MLX 0.31.2, forgetting to set it raises a clear
2418+
`EMLX.NIFError` (`"[metal::start_capture] Failed to start: Capture layer
2419+
is not inserted."`) rather than silently no-op'ing — confirmed by direct
2420+
test, contradicting this NIF's original design assumption that
2421+
`start_capture`/`stop_capture` never throw on ordinary misconfiguration.
2422+
Still worth confirming `path` exists and is non-empty after
2423+
`metal_stop_capture/0` as a belt-and-suspenders check, since a future MLX
2424+
version could change this behavior back to a silent no-op.
2425+
2426+
## Examples
2427+
2428+
# export MTL_CAPTURE_ENABLED=1 (before starting the BEAM)
2429+
EMLX.metal_start_capture("/tmp/trace.gputrace")
2430+
# ... run some MLX work ...
2431+
EMLX.metal_stop_capture()
2432+
"""
2433+
def metal_start_capture(path) when is_binary(path) do
2434+
EMLX.NIF.metal_start_capture(path) |> unwrap!()
2435+
end
2436+
2437+
@doc """
2438+
Stops a Metal GPU frame capture started with `metal_start_capture/1`.
2439+
2440+
See `metal_start_capture/1` for the `MTL_CAPTURE_ENABLED` precondition.
2441+
"""
2442+
def metal_stop_capture, do: EMLX.NIF.metal_stop_capture() |> unwrap!()
24092443
end

emlx/lib/emlx/fast.ex

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ defmodule EMLX.Fast do
5656
- `scaled_dot_product_attention_causal_key_masked/5` — causal SDPA; checks key_mask at C++ level, fast-paths to pure causal when all-ones
5757
- `scaled_dot_product_attention_causal_key_masked/6` — same, plus `opts` (`:sinks`)
5858
- `swiglu/2` — fused SwiGLU: `silu(gate) * up`
59+
- `kv_cache_sdpa_update/8` — fused decode-time KV-cache write + causal SDPA
60+
read, 3 outputs (`{attn_out, k_cache_updated, v_cache_updated}`) —
61+
inference/decode-loop only, no grad support
5962
- `einsum/2` — variadic-operand Einstein summation (`mlx::core::einsum`);
6063
**eager-only, not defn-safe** (see its docs)
6164
@@ -456,6 +459,133 @@ defmodule EMLX.Fast do
456459
if Nx.type(out) != dtype, do: Nx.as_type(out, dtype), else: out
457460
end
458461

462+
# ── Fused decode-time KV-cache update + SDPA ────────────────────────────────
463+
#
464+
# SPIKE for `.work/performance_gap/01-attention-block-fusion-via-compiler.md`
465+
# (Procedure step 3), built after that stage's Results already concluded the
466+
# write-donation fix has no plausible mechanism to help (donation cannot
467+
# survive `:while`-carry storage regardless of which primitive performs the
468+
# write — see that doc's "Fix surface"/"Recommendation" sections) — kept as
469+
# a real, working artifact per explicit instruction to build it anyway, not
470+
# because a throughput win is expected. Unlike every other function in this
471+
# module, this one does NOT use the single-output `:__EMLX__` metadata
472+
# mechanism (`emlx_metadata/4` above) — that mechanism produces exactly one
473+
# physical `ref` (see `EMLX.Native.Expr`'s `:metadata` expand_node clause),
474+
# and this op has 3 outputs (attn_out, k_upd, v_upd). Instead it uses
475+
# `Nx.runtime_call/4`'s `:__emlx_native_multi_op__` escape hatch (see that
476+
# same module's `:runtime_call` expand_node clause), which reuses
477+
# `Nx.runtime_call`'s existing multi-output container/`:elem` machinery
478+
# while still lowering to one `multi_op_registry` C++ instruction — no BEAM
479+
# round-trip, same as every other op here, just without the extra metadata
480+
# wrapper layer (which the multi-output case can't use).
481+
#
482+
# No `Nx.Defn.grad` support — this op only targets the (non-differentiable,
483+
# forward-only) decode/generation loop.
484+
485+
@doc """
486+
Fused decode-time KV-cache write (2× `put_slice`) + causal-masked SDPA read,
487+
as one native op — the traced-graph analog of the hand-written
488+
`EMLX.kv_cache_sdpa_update/7` eager NIF, but operating on the *full*
489+
fixed-size preallocated cache with a *dynamic* (array-valued) `offset`
490+
instead of a host int, so it can sit inside a compiled `:while` decode
491+
loop without forcing a host readback per iteration.
492+
493+
All tensors use **Bumblebee-native, heads-NOT-transposed** layout (matching
494+
`Bumblebee.Layers.Decoder.attention_cache/4`'s `{batch, seq, heads, head_dim}`
495+
cache shape and `EMLXAxon`'s `build_sdpa_layer/5` pre-transpose inputs) — the
496+
head-transpose SDPA itself needs is done internally, once, on `q` and on the
497+
already-written `k_upd`/`v_upd`; the returned caches stay untransposed so
498+
they can feed a `:while` carry directly with no extra transpose.
499+
500+
- `q` — `{B, T_q, N_q, D}` (T_q = 1 for decode; not enforced)
501+
- `new_k` — `{B, T_q, N_kv, D}`
502+
- `new_v` — `{B, T_q, N_kv, D}`
503+
- `k_cache` — `{B, T_max, N_kv, D}`
504+
- `v_cache` — `{B, T_max, N_kv, D}`
505+
- `offset` — scalar/`{1}` int tensor — write position along the `T_max` axis
506+
- `key_mask` — `{B, T_max}` — 1 = valid (readable), 0 = masked
507+
- `scale` — pre-computed scalar
508+
509+
Returns `{attn_out, k_cache_updated, v_cache_updated}` — `attn_out` is
510+
**head-transposed**, `{B, N_q, T_q, D}` (matching what the unfused
511+
`scaled_dot_product_attention_causal_key_masked/6` metadata node itself
512+
already returns; callers transpose it back same as today), while the
513+
updated caches keep `k_cache`/`v_cache`'s own (untransposed) shape.
514+
515+
Eager: composes plain `Nx.put_slice` ×2 (clamped host offset) +
516+
`scaled_dot_product_attention_causal_key_masked/6` — deliberately
517+
independent of the traced path's C++ formula below, so it doubles as an
518+
correctness oracle for the native `multi_op_registry["kv_cache_sdpa_update"]`
519+
kernel (same combined causal+key_mask formula, reimplemented once in
520+
Elixir/Nx and once in C++).
521+
522+
Traced: lowers to one `multi_op_registry["kv_cache_sdpa_update"]`
523+
(`emlx_compiler.cpp`) native IR instruction.
524+
"""
525+
deftransform kv_cache_sdpa_update(q, new_k, new_v, k_cache, v_cache, offset, key_mask, scale) do
526+
t_q = elem(Nx.shape(q), 1)
527+
t_max = elem(Nx.shape(k_cache), 1)
528+
kv_offset = if t_q == 1, do: t_max - 1, else: 0
529+
530+
offset = offset |> Nx.reshape({1}) |> Nx.as_type({:s, 32})
531+
args = {q, new_k, new_v, k_cache, v_cache, offset, key_mask}
532+
533+
if traced?([q, new_k, new_v, k_cache, v_cache, offset, key_mask]) do
534+
{qb, qt, qn, qd} = Nx.shape(q)
535+
attn_template = Nx.template({qb, qn, qt, qd}, Nx.type(q))
536+
537+
out_template = {attn_template, Nx.to_template(k_cache), Nx.to_template(v_cache)}
538+
539+
Nx.runtime_call(
540+
out_template,
541+
args,
542+
[
543+
scale: scale,
544+
kv_offset: kv_offset,
545+
__emlx_native_multi_op__:
546+
{:kv_cache_sdpa_update, [NativeExpr.f64_bits(scale), kv_offset]}
547+
],
548+
&kv_cache_sdpa_update_callback/2
549+
)
550+
else
551+
kv_cache_sdpa_update_callback(args, scale: scale, kv_offset: kv_offset)
552+
end
553+
end
554+
555+
@doc false
556+
def kv_cache_sdpa_update_callback(
557+
{%Nx.Tensor{} = q, %Nx.Tensor{} = new_k, %Nx.Tensor{} = new_v, %Nx.Tensor{} = k_cache,
558+
%Nx.Tensor{} = v_cache, %Nx.Tensor{} = offset, %Nx.Tensor{} = key_mask},
559+
opts
560+
) do
561+
scale = opts[:scale]
562+
kv_offset = opts[:kv_offset]
563+
t_max = elem(Nx.shape(k_cache), 1)
564+
565+
start =
566+
offset
567+
|> Nx.reshape({})
568+
|> Nx.as_type({:s, 64})
569+
|> Nx.to_number()
570+
|> max(0)
571+
|> min(t_max - 1)
572+
573+
k_upd = Nx.put_slice(k_cache, [0, start, 0, 0], new_k)
574+
v_upd = Nx.put_slice(v_cache, [0, start, 0, 0], new_v)
575+
576+
q_t = Nx.transpose(q, axes: [0, 2, 1, 3])
577+
k_t = Nx.transpose(k_upd, axes: [0, 2, 1, 3])
578+
v_t = Nx.transpose(v_upd, axes: [0, 2, 1, 3])
579+
580+
attn_out =
581+
sdpa_causal_key_masked_callback({q_t, k_t, v_t, key_mask},
582+
scale: scale,
583+
kv_offset: kv_offset
584+
)
585+
586+
{attn_out, k_upd, v_upd}
587+
end
588+
459589
# ── RoPE ────────────────────────────────────────────────────────────────────
460590

461591
@doc """

0 commit comments

Comments
 (0)