Current section

Files

Jump to
emlx c_src emlx_fast.cpp
Raw

c_src/emlx_fast.cpp

#include "emlx_nif_shared.hpp"
// mlx::fast ops — single fused Metal shaders
// ============================================================================
// fast_rms_norm — fused RMS normalisation
// MLX: mlx::fast::rms_norm(x, weight, eps, stream) → array, same shape as x
// weight is optional<array> in the C++ API; TENSOR_PARAM gives array* which
// implicitly converts to optional<array>.
NIF(fast_rms_norm) {
TENSOR_PARAM(0, x);
TENSOR_PARAM(1, weight);
PARAM(2, double, eps);
DEVICE_PARAM(3, device);
TENSOR(fast::rms_norm(*x, *weight, (float)eps, device));
}
ASYNC_NIF(fast_rms_norm)
// fast_rope — fused rotary position embedding (scalar offset arity)
// MLX: mlx::fast::rope(x, dims, traditional, base, scale, offset, freqs, stream)
// base is optional<float>; we always supply it.
// traditional=false → split-half (Qwen3 convention).
NIF(fast_rope) {
TENSOR_PARAM(0, a);
PARAM(1, int, dims);
PARAM(2, bool, traditional);
PARAM(3, double, base);
PARAM(4, double, scale);
PARAM(5, int, offset);
DEVICE_PARAM(6, device);
TENSOR(fast::rope(*a, dims, traditional, (float)base, (float)scale,
offset, std::nullopt, device));
}
ASYNC_NIF(fast_rope)
// fast_sdpa — flash-attention SDPA, no mask
// MLX: mlx::fast::scaled_dot_product_attention(q, k, v, scale, mask_mode, mask_arr, sinks, stream)
// GQA-native: k/v may have fewer heads than q.
NIF(fast_sdpa) {
TENSOR_PARAM(0, q);
TENSOR_PARAM(1, k);
TENSOR_PARAM(2, v);
PARAM(3, double, scale);
DEVICE_PARAM(4, device);
TENSOR(fast::scaled_dot_product_attention(
*q, *k, *v, (float)scale, "", std::nullopt, std::nullopt, device));
}
ASYNC_NIF(fast_sdpa)
// fast_sdpa_masked — flash-attention SDPA with additive or boolean mask
// mask_mode="array" tells MLX to treat mask_arr as an additive bias/bool mask.
NIF(fast_sdpa_masked) {
TENSOR_PARAM(0, q);
TENSOR_PARAM(1, k);
TENSOR_PARAM(2, v);
PARAM(3, double, scale);
TENSOR_PARAM(4, mask);
DEVICE_PARAM(5, device);
TENSOR(fast::scaled_dot_product_attention(
*q, *k, *v, (float)scale, "array", *mask, std::nullopt, device));
}
ASYNC_NIF(fast_sdpa_masked)
// fast_layer_norm — fused layer normalisation
// MLX: mlx::fast::layer_norm(x, weight?, bias?, eps, stream)
// weight and bias are optional; we always provide both.
NIF(fast_layer_norm) {
TENSOR_PARAM(0, x);
TENSOR_PARAM(1, weight);
TENSOR_PARAM(2, bias);
PARAM(3, double, eps);
DEVICE_PARAM(4, device);
TENSOR(fast::layer_norm(*x, *weight, *bias, (float)eps, device));
}
ASYNC_NIF(fast_layer_norm)
// fast_layer_norm_no_bias — fused layer norm without bias (weight-only variant)
NIF(fast_layer_norm_no_bias) {
TENSOR_PARAM(0, x);
TENSOR_PARAM(1, weight);
PARAM(2, double, eps);
DEVICE_PARAM(3, device);
TENSOR(fast::layer_norm(*x, *weight, std::nullopt, (float)eps, device));
}
ASYNC_NIF(fast_layer_norm_no_bias)
// fast_rope_ids — fused RoPE with per-batch offset array (position_ids)
// Calls the array-offset overload of mlx::fast::rope.
// offset must be shape {B} — one starting position per batch example.
// Assumes positions are sequential within each example: [offset[b], offset[b]+1, ..., offset[b]+T-1].
NIF(fast_rope_ids) {
TENSOR_PARAM(0, a);
PARAM(1, int, dims);
PARAM(2, bool, traditional);
PARAM(3, double, base);
PARAM(4, double, scale);
TENSOR_PARAM(5, offset);
DEVICE_PARAM(6, device);
TENSOR(fast::rope(*a, dims, traditional, (float)base, (float)scale,
*offset, std::nullopt, device));
}
ASYNC_NIF(fast_rope_ids)
// fast_rope_with_freqs — fused RoPE with precomputed inv-frequency vector
// Calls the freqs overload of mlx::fast::rope (base=nullopt, freqs supplied).
// offset must be shape {B} — one starting position per batch example.
// freqs must be shape {dims/2} — precomputed inverse frequencies.
NIF(fast_rope_with_freqs) {
TENSOR_PARAM(0, a);
PARAM(1, int, dims);
PARAM(2, bool, traditional);
PARAM(3, double, scale);
TENSOR_PARAM(4, offset);
TENSOR_PARAM(5, freqs);
DEVICE_PARAM(6, device);
TENSOR(fast::rope(*a, dims, traditional, std::nullopt, (float)scale,
*offset, *freqs, device));
}
ASYNC_NIF(fast_rope_with_freqs)
// fast_rope_positions — RoPE for arbitrary per-token position_ids.
//
// Unlike fast_rope_ids, this does not assume positions are sequential within a
// row. It mirrors Bumblebee's apply_rotary_embedding:
// inv_freq = 1 / base^(2i/dims)
// angle = position_ids * inv_freq
// out = a * cos(angle) + rotate_half(a) * sin(angle)
//
// Input:
// a {B, T, H, D}
// position_ids {B, T}
//
// Notes:
// - `traditional=true` is currently unsupported in this fallback NIF.
// - Intended for high-base / padded paths where fast_rope_ids is not correct.
NIF(fast_rope_positions) {
TENSOR_PARAM(0, a);
PARAM(1, int, dims);
PARAM(2, bool, traditional);
PARAM(3, double, base);
PARAM(4, double, scale);
TENSOR_PARAM(5, position_ids);
DEVICE_PARAM(6, device);
try {
if (a->ndim() != 4) {
return nx::nif::error(env, "fast_rope_positions expects a rank-4 tensor a {B,T,H,D}");
}
if (position_ids->ndim() != 2) {
return nx::nif::error(env, "fast_rope_positions expects rank-2 position_ids {B,T}");
}
int B = a->shape(0);
int T = a->shape(1);
int H = a->shape(2);
int D = a->shape(3);
if (position_ids->shape(0) != B || position_ids->shape(1) != T) {
return nx::nif::error(env, "fast_rope_positions: position_ids shape must match {B,T} from a");
}
if (dims <= 0 || dims > D || (dims % 2) != 0) {
return nx::nif::error(env, "fast_rope_positions: dims must be even and <= last dimension");
}
if (traditional) {
return nx::nif::error(env, "fast_rope_positions: traditional=true not supported");
}
int half = dims / 2;
std::vector<float> inv_freq_host(half);
float base_f = static_cast<float>(base);
for (int i = 0; i < half; ++i) {
float expo = (2.0f * static_cast<float>(i)) / static_cast<float>(dims);
inv_freq_host[i] = 1.0f / std::pow(base_f, expo);
}
auto inv_freq = array(inv_freq_host.begin(), {half}, float32);
auto pos_f = astype(*position_ids, float32, device);
auto pos_bt1 = reshape(pos_f, {B, T, 1}, device);
auto inv_11h = reshape(inv_freq, {1, 1, half}, device);
auto scale_arr = array(static_cast<float>(scale), float32);
auto angles = multiply(multiply(pos_bt1, inv_11h, device), scale_arr, device);
auto cos_bt1h = astype(reshape(cos(angles, device), {B, T, 1, half}, device), a->dtype(), device);
auto sin_bt1h = astype(reshape(sin(angles, device), {B, T, 1, half}, device), a->dtype(), device);
auto cos_full = concatenate(std::vector<array>{cos_bt1h, cos_bt1h}, 3, device);
auto sin_full = concatenate(std::vector<array>{sin_bt1h, sin_bt1h}, 3, device);
auto x1 = slice(*a, to_shape({0, 0, 0, 0}), to_shape({B, T, H, half}), device);
auto x2 = slice(*a, to_shape({0, 0, 0, half}), to_shape({B, T, H, dims}), device);
auto rotated = concatenate(std::vector<array>{negative(x2, device), x1}, 3, device);
auto a_head = slice(*a, to_shape({0, 0, 0, 0}), to_shape({B, T, H, dims}), device);
auto rope_head = add(multiply(a_head, cos_full, device), multiply(rotated, sin_full, device), device);
if (dims == D) {
TENSOR(rope_head);
} else {
auto tail = slice(*a, to_shape({0, 0, 0, dims}), to_shape({B, T, H, D}), device);
TENSOR(concatenate(std::vector<array>{rope_head, tail}, 3, device));
}
}
CATCH()
}
ASYNC_NIF(fast_rope_positions)
// fast_sdpa_causal_key_masked — causal SDPA that checks key_mask at C++ level.
// key_mask shape: {B, T_kv} — 1 = attend, 0 = padding.
// If all values are 1 (no padding), dispatches to fast causal SDPA (no mask alloc).
// Otherwise builds a combined causal + key_mask additive float mask and uses
// masked SDPA. The all-ones check forces eval of only the small key_mask subgraph.
NIF(fast_sdpa_causal_key_masked) {
TENSOR_PARAM(0, q); // {B, N_q, T_q, D}
TENSOR_PARAM(1, k); // {B, N_kv, T_kv, D}
TENSOR_PARAM(2, v); // {B, N_kv, T_kv, D}
PARAM(3, double, scale);
TENSOR_PARAM(4, key_mask); // {B, T_kv} boolean / int
PARAM(5, int, kv_offset); // caller-controlled: decode → T_kv-1, prefill → 0
DEVICE_PARAM(6, device);
// key_mask values are 0/1 (int or bool); astype→bool_ then all() checks all non-zero.
bool trivial = all(astype(*key_mask, bool_)).item<bool>();
if (trivial) {
TENSOR(fast::scaled_dot_product_attention(
*q, *k, *v, (float)scale, "causal", std::nullopt, std::nullopt, device));
} else {
// Expand key_mask {B, T_kv} → {B, 1, 1, T_kv} for broadcasting.
auto km = reshape(*key_mask, {key_mask->shape(0), 1, 1, key_mask->shape(1)});
int T_q = q->shape(2);
int T_kv = k->shape(2);
// Causal boolean: position j is visible from query row i if j <= i + kv_offset.
auto row = reshape(arange(T_q, int32), {1, 1, T_q, 1});
auto col = reshape(arange(T_kv, int32), {1, 1, 1, T_kv});
auto causal_bool = less_equal(col, add(row, array(kv_offset, int32)));
// Combined: attend where key_mask=1 AND position is causally reachable.
auto keep = logical_and(km, causal_bool);
// Additive mask: 0.0 = attend, -inf = masked out.
// Use Q's dtype so the mask does not promote the SDPA output above Q's type.
// (e.g. float32 mask + bfloat16 Q/K/V would force float32 output — rejected by MLX.)
auto mask_dtype = q->dtype();
auto zero_val = zeros({}, mask_dtype);
auto neginf_val = full({}, -std::numeric_limits<float>::infinity(), mask_dtype);
auto additive = where(keep, zero_val, neginf_val);
TENSOR(fast::scaled_dot_product_attention(
*q, *k, *v, (float)scale, "array", additive, std::nullopt, device));
}
}
ASYNC_NIF(fast_sdpa_causal_key_masked)
// fast_swiglu — fused SwiGLU: silu(gate) * up
// SiLU (Sigmoid Linear Unit): silu(x) = x * sigmoid(x).
// gate and up must have the same shape; output has the same shape and dtype.
NIF(fast_swiglu) {
TENSOR_PARAM(0, gate);
TENSOR_PARAM(1, up);
DEVICE_PARAM(2, device);
// silu(gate) * up where silu(x) = x * sigmoid(x).
// MLX's lazy graph evaluation fuses these into a single kernel dispatch.
TENSOR(multiply(multiply(*gate, sigmoid(*gate, device), device), *up, device));
}
ASYNC_NIF(fast_swiglu)
NIF(fast_sdpa_causal) {
TENSOR_PARAM(0, q);
TENSOR_PARAM(1, k);
TENSOR_PARAM(2, v);
PARAM(3, double, scale);
DEVICE_PARAM(4, device);
TENSOR(fast::scaled_dot_product_attention(
*q, *k, *v, (float)scale, "causal", std::nullopt, std::nullopt, device));
}
ASYNC_NIF(fast_sdpa_causal)
// kv_cache_attention — fused KV cache update + variable-length SDPA in one Metal pass.
//
// Inputs (Bumblebee {B, T, N, D} convention — no pre-transpose needed from caller):
// q — {B, T_q, N_q, D} post-RoPE query
// new_k — {B, T_new, N_kv, D} post-RoPE key for new token(s)
// new_v — {B, T_new, N_kv, D} value for new token(s)
// k_cache — {B, T_max, N_kv, D} pre-allocated key buffer (ETS side-channel)
// v_cache — {B, T_max, N_kv, D} pre-allocated value buffer
// offset — int number of valid positions already in cache
// scale — float 1/sqrt(head_dim)
//
// Returns 3-tuple {attn_out, k_upd, v_upd}:
// attn_out — {B, T_q, N_q, D} (Bumblebee format, ready for flatten_trailing + o_proj)
// k_upd — {B, T_max, N_kv, D} full updated key buffer (for ETS insert)
// v_upd — {B, T_max, N_kv, D} full updated value buffer (for ETS insert)
//
// All three are eval'd in a single MLX command buffer — slice_update → valid_slice
// → SDPA form one fused Metal encoder submission.
NIF(kv_cache_attention) {
TENSOR_PARAM(0, q);
TENSOR_PARAM(1, new_k);
TENSOR_PARAM(2, new_v);
TENSOR_PARAM(3, k_cache);
TENSOR_PARAM(4, v_cache);
PARAM(5, int, offset);
PARAM(6, double, scale);
DEVICE_PARAM(7, device);
try {
int B = q->shape(0);
int T_q = q->shape(1);
int D = q->shape(3);
int N_kv = new_k->shape(2);
int T_new = new_k->shape(1);
int valid_len = offset + T_new;
// Donate the cache buffers so MLX can reuse them in-place.
// After std::move, the ENIF resource blocks hold moved-from arrays.
// k_cache_owned / v_cache_owned are the sole shared_ptr owners until
// slice_update copies them into its inputs list (count rises to 2).
// When this function returns, those locals destruct → count drops to 1
// → SliceUpdate::eval_gpu detects is_donatable() → no new 4 MB buffer.
auto k_cache_owned = std::move(*k_cache);
auto v_cache_owned = std::move(*v_cache);
// 1. Insert new K/V at cache position `offset`.
// Output has the same shape as k_cache: {B, T_max, N_kv, D}.
auto k_upd = mlx::core::slice_update(
k_cache_owned, *new_k,
to_shape({0, offset, 0, 0}),
to_shape({B, valid_len, N_kv, D}),
device);
auto v_upd = mlx::core::slice_update(
v_cache_owned, *new_v,
to_shape({0, offset, 0, 0}),
to_shape({B, valid_len, N_kv, D}),
device);
// 2. Slice to valid portion: {B, valid_len, N_kv, D}.
auto k_valid = mlx::core::slice(
k_upd, to_shape({0, 0, 0, 0}), to_shape({B, valid_len, N_kv, D}), device);
auto v_valid = mlx::core::slice(
v_upd, to_shape({0, 0, 0, 0}), to_shape({B, valid_len, N_kv, D}), device);
// 3. Transpose from Bumblebee {B, T, N, D} to MLX SDPA {B, N, T, D}.
auto q_t = mlx::core::transpose(*q, {0, 2, 1, 3}, device);
auto k_valid_t = mlx::core::transpose(k_valid, {0, 2, 1, 3}, device);
auto v_valid_t = mlx::core::transpose(v_valid, {0, 2, 1, 3}, device);
// 4. SDPA over the valid slice.
// T_new == 1 (decode): no mask — single query is trivially causal.
// T_new > 1 (prefill): build an additive causal mask in q's dtype to avoid
// any float32 promotion that would mismatch BF16 Q/K/V tensors.
auto build_prefill_mask = [&]() -> mlx::core::array {
auto mask_dtype = q->dtype();
auto zero_val = mlx::core::zeros({}, mask_dtype, device);
auto neginf_val = mlx::core::full({}, -std::numeric_limits<float>::infinity(), mask_dtype, device);
// Causal: query position i can attend to key position j iff j <= i + kv_offset.
int kv_offset = valid_len - T_q;
auto row = mlx::core::reshape(mlx::core::arange(T_q, mlx::core::int32, device), {1, 1, T_q, 1}, device);
auto col = mlx::core::reshape(mlx::core::arange(valid_len, mlx::core::int32, device), {1, 1, 1, valid_len}, device);
auto causal_bool = mlx::core::less_equal(
col, mlx::core::add(row, mlx::core::array(kv_offset, mlx::core::int32), device), device);
return mlx::core::where(causal_bool, zero_val, neginf_val, device);
};
auto attn_t = (T_new == 1)
? mlx::core::fast::scaled_dot_product_attention(
q_t, k_valid_t, v_valid_t, (float)scale, "", std::nullopt, std::nullopt, device)
: mlx::core::fast::scaled_dot_product_attention(
q_t, k_valid_t, v_valid_t, (float)scale, "array", build_prefill_mask(), std::nullopt, device);
// 5. Transpose output back: {B, N_q, T_q, D} → {B, T_q, N_q, D} (Bumblebee format).
auto attn_out = mlx::core::transpose(attn_t, {0, 2, 1, 3}, device);
ERL_NIF_TERM result_tuple[3];
result_tuple[0] = create_tensor_resource(env, attn_out);
result_tuple[1] = create_tensor_resource(env, k_upd);
result_tuple[2] = create_tensor_resource(env, v_upd);
return nx::nif::ok(env, enif_make_tuple3(env, result_tuple[0], result_tuple[1], result_tuple[2]));
}
CATCH()
}
ASYNC_NIF(kv_cache_attention)
// kv_cache_attention_masked — same as kv_cache_attention but applies a key_mask
// in addition to the causal mask. This is required for padded prefill where the
// first `padding_len` key positions contain garbage and must be excluded from
// attention in both the prefill pass and subsequent decode steps.
//
// Extra parameter vs kv_cache_attention:
// key_mask — {B, T_kv} with 1 = attend, 0 = padding-skip
//
// The combined additive mask: 0 where (key_mask=1 AND causal), -inf otherwise.
// This is built directly without calling `all()` to avoid triggering Metal sort
// kernels that may not be available for all tensor shapes.
//
// Returns same 3-tuple as kv_cache_attention: {attn_out, k_upd, v_upd}.
NIF(kv_cache_attention_masked) {
TENSOR_PARAM(0, q); // {B, T_q, N_q, D} Bumblebee format
TENSOR_PARAM(1, new_k); // {B, T_new, N_kv, D}
TENSOR_PARAM(2, new_v); // {B, T_new, N_kv, D}
TENSOR_PARAM(3, k_cache); // {B, T_max, N_kv, D}
TENSOR_PARAM(4, v_cache); // {B, T_max, N_kv, D}
PARAM(5, int, offset);
PARAM(6, double, scale);
TENSOR_PARAM(7, key_mask); // {B, T_kv} — 1=attend, 0=skip
DEVICE_PARAM(8, device);
try {
int B = q->shape(0);
int T_q = q->shape(1);
int D = q->shape(3);
int N_kv = new_k->shape(2);
int T_new = new_k->shape(1);
int valid_len = offset + T_new;
// Donate cache buffers (same pattern as kv_cache_attention).
auto k_cache_owned = std::move(*k_cache);
auto v_cache_owned = std::move(*v_cache);
// 1. Insert new K/V at cache position `offset`.
auto k_upd = mlx::core::slice_update(
k_cache_owned, *new_k,
to_shape({0, offset, 0, 0}),
to_shape({B, valid_len, N_kv, D}),
device);
auto v_upd = mlx::core::slice_update(
v_cache_owned, *new_v,
to_shape({0, offset, 0, 0}),
to_shape({B, valid_len, N_kv, D}),
device);
// 2. Slice to valid portion.
auto k_valid = mlx::core::slice(
k_upd, to_shape({0, 0, 0, 0}), to_shape({B, valid_len, N_kv, D}), device);
auto v_valid = mlx::core::slice(
v_upd, to_shape({0, 0, 0, 0}), to_shape({B, valid_len, N_kv, D}), device);
// 3. Transpose from Bumblebee {B, T, N, D} to MLX SDPA {B, N, T, D}.
auto q_t = mlx::core::transpose(*q, {0, 2, 1, 3}, device);
auto k_valid_t = mlx::core::transpose(k_valid, {0, 2, 1, 3}, device);
auto v_valid_t = mlx::core::transpose(v_valid, {0, 2, 1, 3}, device);
// 4. Compute attention output.
//
// For decode (T_q == 1), the causal constraint is trivially satisfied: a single
// query position always sees all valid keys. We skip the arange/reshape/less_equal
// construction and dispatch to the cheapest SDPA variant:
// - trivial key_mask (all-ones, non-padded batch): pure causal Metal kernel.
// - non-trivial key_mask (padded batch): key_mask-only additive mask.
// The all().item<bool>() sync is negligible at {B, valid_len} (1–256 elements).
//
// For prefill (T_q > 1), build the full causal + key_mask combined additive mask.
auto mask_dtype = q->dtype();
auto zero_val = mlx::core::zeros({}, mask_dtype, device);
auto neginf_val = mlx::core::full({}, -std::numeric_limits<float>::infinity(), mask_dtype, device);
mlx::core::array attn_t = [&]() -> mlx::core::array {
if (T_q == 1) {
// For single-query decode, the causal constraint is trivially satisfied
// (one query always attends to all preceding keys). Build a key_mask-only
// additive mask — 3 GPU ops vs 8 in the original full causal+mask path.
//
// We intentionally skip the all().item<bool>() trivial check here: that
// check forces a GPU→CPU sync on every layer call (28×/step), whose
// latency cost exceeds the savings from choosing "causal" mode. For
// non-padded inference the additive mask is all-zeros, which is
// functionally identical to pure causal mode.
auto km = mlx::core::reshape(
mlx::core::astype(*key_mask, mlx::core::bool_, device),
{key_mask->shape(0), 1, 1, key_mask->shape(1)},
device);
auto additive = mlx::core::where(km, zero_val, neginf_val, device);
return mlx::core::fast::scaled_dot_product_attention(
q_t, k_valid_t, v_valid_t, (float)scale, "array",
additive, std::nullopt, device);
} else {
// Prefill (T_q > 1): full causal + key_mask combined additive mask.
// kv_offset: valid_len - T_q (= 0 for a fresh prefill of length T_q).
int kv_offset = valid_len - T_q;
auto row = mlx::core::reshape(
mlx::core::arange(T_q, mlx::core::int32, device), {1, 1, T_q, 1}, device);
auto col = mlx::core::reshape(
mlx::core::arange(valid_len, mlx::core::int32, device), {1, 1, 1, valid_len}, device);
auto causal_bool = mlx::core::less_equal(
col, mlx::core::add(row, mlx::core::array(kv_offset, mlx::core::int32), device), device);
auto km = mlx::core::reshape(
mlx::core::astype(*key_mask, mlx::core::bool_, device),
{key_mask->shape(0), 1, 1, key_mask->shape(1)},
device);
auto keep = mlx::core::logical_and(km, causal_bool, device);
auto additive = mlx::core::where(keep, zero_val, neginf_val, device);
return mlx::core::fast::scaled_dot_product_attention(
q_t, k_valid_t, v_valid_t, (float)scale, "array",
additive, std::nullopt, device);
}
}();
// Replace NaN with 0 for all-masked rows (softmax(-inf,...,-inf) = NaN in
// Flash-Attention when seq_len >= Metal tile size, but semantically = 0).
auto attn_safe = mlx::core::where(
mlx::core::isnan(attn_t),
mlx::core::zeros_like(attn_t, device),
attn_t, device);
// 6. Transpose output back: {B, N_q, T_q, D} → {B, T_q, N_q, D}.
auto attn_out = mlx::core::transpose(attn_safe, {0, 2, 1, 3}, device);
ERL_NIF_TERM result_tuple[3];
result_tuple[0] = create_tensor_resource(env, attn_out);
result_tuple[1] = create_tensor_resource(env, k_upd);
result_tuple[2] = create_tensor_resource(env, v_upd);
return nx::nif::ok(env, enif_make_tuple3(env, result_tuple[0], result_tuple[1], result_tuple[2]));
}
CATCH()
}
ASYNC_NIF(kv_cache_attention_masked)
// kv_cache_sdpa_update — fused donation-optimised KV cache update + SDPA
// for the native NIF loop (BNHD layout: {B, N, T, D}).
//
// The Bumblebee path uses {B, T, N, D} and goes through kv_cache_attention.
// This variant accepts q / new_k / new_v already transposed to {B, N, T, D}
// (as done by the native loop before put_slice), with the cache stored in the
// same {B, N_kv, T_max, D} layout.
//
// DONATION SEMANTICS: k_cache and v_cache are move-extracted from their ENIF
// resource blocks before slice_update. When this function returns those
// locals destruct, dropping their shared_ptr refs from 2 → 1. Only
// slice_update's inputs list retains the reference → SliceUpdate::eval_gpu
// detects is_donatable() → the existing 4 MB Metal buffer is reused in-place,
// no new allocation needed.
//
// Inputs (argv indices inside the sync NIF body, after worker is stripped):
// q — {B, N_q, T_q, D} post-RoPE query (native BNHD)
// new_k — {B, N_kv, T_new, D} post-RoPE key
// new_v — {B, N_kv, T_new, D} value
// k_cache — {B, N_kv, T_max, D} pre-allocated key buffer
// v_cache — {B, N_kv, T_max, D} pre-allocated value buffer
// offset — int tokens already in cache
// scale — float 1/sqrt(head_dim)
// device — atom
//
// Returns {attn_out, k_upd, v_upd}:
// attn_out — {B, N_q, T_q, D} (native BNHD, caller transposes/reshapes)
// k_upd — {B, N_kv, T_max, D} same Metal buffer as k_cache, updated
// v_upd — {B, N_kv, T_max, D} same Metal buffer as v_cache, updated
NIF(kv_cache_sdpa_update) {
TENSOR_PARAM(0, q);
TENSOR_PARAM(1, new_k);
TENSOR_PARAM(2, new_v);
TENSOR_PARAM(3, k_cache);
TENSOR_PARAM(4, v_cache);
PARAM(5, int, offset);
PARAM(6, double, scale);
DEVICE_PARAM(7, device);
try {
int B = q->shape(0);
int N_kv = new_k->shape(1);
int T_new = new_k->shape(2);
int D = q->shape(3);
int valid_len = offset + T_new;
// Move-extract: after this the ENIF resource blocks hold moved-from arrays.
// k_cache_owned / v_cache_owned are the sole owners (use_count == 1) until
// slice_update copies them into its inputs (use_count rises to 2).
// On function return both locals destruct → use_count drops to 1 again
// → is_donatable() is true at eval time → no new 4 MB Metal buffer.
auto k_cache_owned = std::move(*k_cache);
auto v_cache_owned = std::move(*v_cache);
// 1. Insert new_k / new_v at cache position `offset` along axis 2 (T axis).
// Layout: {B, N_kv, T_max, D} — offset along dimension 2.
auto k_upd = mlx::core::slice_update(
k_cache_owned, *new_k,
to_shape({0, 0, offset, 0}),
to_shape({B, N_kv, valid_len, D}),
device);
auto v_upd = mlx::core::slice_update(
v_cache_owned, *new_v,
to_shape({0, 0, offset, 0}),
to_shape({B, N_kv, valid_len, D}),
device);
// 2. Slice valid prefix: k_upd[0:B, 0:N_kv, 0:valid_len, 0:D].
auto k_valid = mlx::core::slice(
k_upd, to_shape({0, 0, 0, 0}), to_shape({B, N_kv, valid_len, D}), device);
auto v_valid = mlx::core::slice(
v_upd, to_shape({0, 0, 0, 0}), to_shape({B, N_kv, valid_len, D}), device);
// 3. SDPA: q / k_valid / v_valid are already in {B, N, T, D} format.
// Decode (T_new == 1): no mask — single query is trivially causal.
// Prefill (T_new > 1): additive causal mask in q's dtype.
auto build_prefill_mask = [&]() -> mlx::core::array {
auto mask_dtype = q->dtype();
auto zero_val = mlx::core::zeros({}, mask_dtype, device);
auto neginf_val = mlx::core::full({}, -std::numeric_limits<float>::infinity(), mask_dtype, device);
int kv_offset = valid_len - T_new;
auto row = mlx::core::reshape(
mlx::core::arange(T_new, mlx::core::int32, device), {1, 1, T_new, 1}, device);
auto col = mlx::core::reshape(
mlx::core::arange(valid_len, mlx::core::int32, device), {1, 1, 1, valid_len}, device);
auto causal_bool = mlx::core::less_equal(
col, mlx::core::add(row, mlx::core::array(kv_offset, mlx::core::int32), device), device);
return mlx::core::where(causal_bool, zero_val, neginf_val, device);
};
auto attn_out = (T_new == 1)
? mlx::core::fast::scaled_dot_product_attention(
*q, k_valid, v_valid, (float)scale, "", std::nullopt, std::nullopt, device)
: mlx::core::fast::scaled_dot_product_attention(
*q, k_valid, v_valid, (float)scale, "array", build_prefill_mask(), std::nullopt, device);
// k_cache_owned and v_cache_owned destruct here → use_count 2 → 1.
ERL_NIF_TERM result_tuple[3];
result_tuple[0] = create_tensor_resource(env, attn_out);
result_tuple[1] = create_tensor_resource(env, k_upd);
result_tuple[2] = create_tensor_resource(env, v_upd);
return nx::nif::ok(env, enif_make_tuple3(env, result_tuple[0], result_tuple[1], result_tuple[2]));
}
CATCH()
}
ASYNC_NIF(kv_cache_sdpa_update)