Single-kernel Metal shaders from mlx::fast, exposed as deftransform
functions.
Every function is defn-safe: call inside defn, Nx.Defn.jit, or from
Axon.rewrite_nodes/2 rewrite callbacks without restriction — except
einsum/2, which is eager-only (see its docs).
Two execution paths
Each deftransform here dispatches on whether its tensor arguments are
concrete (eager — a real EMLX.Backend-backed tensor) or expression-backed
(traced — building an Nx.Defn.Expr graph):
- Eager: the fused NIF (e.g.
EMLX.fast_rms_norm/3) runs immediately. - Traced: the function returns an
Nx.Defn.Expr.metadata/2node carrying a:__EMLX__key —%{op: opcode, operands: [...], attrs: [...]}— naming the native EMLX opcode, its operand tensors, and its int-encoded attributes (seeEMLX.Native.Expr.f64_bits/1).EMLX.Native.Expr's:metadataexpand_nodeclause recognizes this key and lowers straight to the native op — no graph split. The metadata node wraps anNx.runtime_call/4of the same eager callback used above (inner) — this compiler never evaluates it (it's discarded in favor of the:__EMLX__payload); it exists only so (a) the operand tensors are ordinary reachable dependencies forEMLX.Defn.Tree.post_order/2to visit, and (b) any otherNx.Defn.Compiler— notably the defaultNx.Defn.EvaluatorandNx.Defn.Grad— still gets a correct fallback:runtime_calljust runs the real NIF against concrete tensors, so it's both exact (not a slower plain-Nxapproximation) and free to build (unlike a full composite reference formula,Nx.runtime_call/4is a single lightweight node — no per-op sub-expression tracing cost). The:__EMLX__node is itself wrapped once more in aNx.Defn.Kernel.custom_grad/3annotation (with_reference_grad/3) soNx.Defn.graddifferentiates through each op's plain-Nx*_reference/Nformula (VJP viaNx.Defn.Grad.transform/3— seewith_reference_grad/3) instead of hitting the non-differentiableruntime_callforward pass.
A bare Nx.runtime_call (anything not wrapped in :__EMLX__ metadata)
always forces a graph split — see EMLX.split_point?/1.
Functions
rms_norm/3— fused RMS normalisationlayer_norm/4— fused layer normalisation (with bias)layer_norm/3— fused layer normalisation (weight-only, no bias)rope/6— fused RoPE with scalar integer offsetrope_with_positions/6— fused RoPE accepting aposition_idstensorrope_with_freqs/6— fused RoPE with precomputed inv-frequency tensor (for:llama3scaling)scaled_dot_product_attention/4— flash-attention SDPA (no mask)scaled_dot_product_attention/5— flash-attention SDPA, either an additive/boolmasktensor, or anoptskeyword list (:sinks) when there's no maskscaled_dot_product_attention/6— flash-attention SDPA withmaskandopts(:sinks)scaled_dot_product_attention_causal/4— flash-attention SDPA with built-in causal maskscaled_dot_product_attention_causal/5— same, plusopts(:sinks)scaled_dot_product_attention_causal_key_masked/5— causal SDPA; checks key_mask at C++ level, fast-paths to pure causal when all-onesscaled_dot_product_attention_causal_key_masked/6— same, plusopts(:sinks)swiglu/2— fused SwiGLU:silu(gate) * upkv_cache_sdpa_update/8— fused decode-time KV-cache write + causal SDPA read, 3 outputs ({attn_out, k_cache_updated, v_cache_updated}) — inference/decode-loop only, no grad supporteinsum/2— variadic-operand Einstein summation (mlx::core::einsum); eager-only, not defn-safe (see its docs)
Axon graph rewrite example
Axon.rewrite_nodes(model, fn
%Axon.Node{op: :rms_norm, opts: [eps: eps]} ->
fn [x, weight], _output -> EMLX.Fast.rms_norm(x, weight, eps) end
_ -> :skip
end)
Summary
Functions
Variadic-operand einsum computed by MLX's path-optimised
mlx::core::einsum kernel.
Fused decode-time KV-cache write (2× put_slice) + causal-masked SDPA read,
as one native op — the traced-graph analog of the hand-written
EMLX.kv_cache_sdpa_update/7 eager NIF, but operating on the full
fixed-size preallocated cache with a dynamic (array-valued) offset
instead of a host int, so it can sit inside a compiled :while decode
loop without forcing a host readback per iteration.
Fused layer normalisation without bias (mlx::fast::layer_norm, weight-only variant).
Fused layer normalisation (mlx::fast::layer_norm).
Fused RMS normalisation (mlx::fast::rms_norm).
Fused rotary position embedding (mlx::fast::rope).
Fused RoPE with precomputed inverse-frequency vector (mlx::fast::rope, freqs overload).
Fused RoPE accepting a position_ids tensor (mlx::fast::rope, array-offset overload).
Flash-attention SDPA, no mask (mlx::fast::scaled_dot_product_attention).
Flash-attention SDPA — either an additive/boolean mask tensor (5th arg),
or, with no mask, an opts keyword list (disambiguated by is_list/1)
Flash-attention SDPA with an additive/boolean mask and opts (:sinks
— see scaled_dot_product_attention/5).
Flash-attention SDPA with a built-in causal mask (mlx::fast::scaled_dot_product_attention,
mask_mode="causal").
Causal flash-attention SDPA with opts (:sinks — see
scaled_dot_product_attention/5).
Causal SDPA with the key_mask check delegated to the C++ NIF (eager) or folded directly into the compiled graph (traced).
Fused SwiGLU activation: silu(gate) * up where silu(x) = x * sigmoid(x).
Functions
Variadic-operand einsum computed by MLX's path-optimised
mlx::core::einsum kernel.
subscripts is a standard Einstein-summation equation (e.g.
"ij,jk->ik", "bij,bjk->bik", "bhid,bhjd->bhij",
"ij,jk,kl->il"). operands is the corresponding list of 2+ tensors.
Eager-only, not defn-callable
Unlike the other helpers in this module, einsum/2 does not emit an
Nx.Defn.Expr node — it takes refs directly off EMLX.Backend-backed
tensors and calls the NIF eagerly, in the same "direct-call helper" style
as EMLX.quantized_matmul/2. Every operand must live on EMLX.Backend;
anything else raises ArgumentError.
Examples
iex> a = Nx.iota({2, 3}, backend: EMLX.Backend, type: :f32)
iex> b = Nx.iota({3, 4}, backend: EMLX.Backend, type: :f32)
iex> y = EMLX.Fast.einsum("ij,jk->ik", [a, b])
iex> Nx.shape(y)
{2, 4}
Fused decode-time KV-cache write (2× put_slice) + causal-masked SDPA read,
as one native op — the traced-graph analog of the hand-written
EMLX.kv_cache_sdpa_update/7 eager NIF, but operating on the full
fixed-size preallocated cache with a dynamic (array-valued) offset
instead of a host int, so it can sit inside a compiled :while decode
loop without forcing a host readback per iteration.
All tensors use Bumblebee-native, heads-NOT-transposed layout (matching
Bumblebee.Layers.Decoder.attention_cache/4's {batch, seq, heads, head_dim}
cache shape and EMLXAxon's build_sdpa_layer/5 pre-transpose inputs) — the
head-transpose SDPA itself needs is done internally, once, on q and on the
already-written k_upd/v_upd; the returned caches stay untransposed so
they can feed a :while carry directly with no extra transpose.
q—{B, T_q, N_q, D}(T_q = 1 for decode; not enforced)new_k—{B, T_q, N_kv, D}new_v—{B, T_q, N_kv, D}k_cache—{B, T_max, N_kv, D}v_cache—{B, T_max, N_kv, D}offset— scalar/{1}int tensor — write position along theT_maxaxiskey_mask—{B, T_max}— 1 = valid (readable), 0 = maskedscale— pre-computed scalar
Returns {attn_out, k_cache_updated, v_cache_updated} — attn_out is
head-transposed, {B, N_q, T_q, D} (matching what the unfused
scaled_dot_product_attention_causal_key_masked/6 metadata node itself
already returns; callers transpose it back same as today), while the
updated caches keep k_cache/v_cache's own (untransposed) shape.
Eager: composes plain Nx.put_slice ×2 (clamped host offset) +
scaled_dot_product_attention_causal_key_masked/6 — deliberately
independent of the traced path's C++ formula below, so it doubles as an
correctness oracle for the native multi_op_registry["kv_cache_sdpa_update"]
kernel (same combined causal+key_mask formula, reimplemented once in
Elixir/Nx and once in C++).
Traced: lowers to one multi_op_registry["kv_cache_sdpa_update"]
(emlx_compiler.cpp) native IR instruction.
Fused layer normalisation without bias (mlx::fast::layer_norm, weight-only variant).
x— input tensor; normalised over the last axis.weight—{hidden}scale vector (gamma).eps— numerical stability constant (e.g.1.0e-5).
Output shape and type match x.
Fused layer normalisation (mlx::fast::layer_norm).
x— input tensor; normalised over the last axis.weight—{hidden}scale vector (gamma).bias—{hidden}bias vector (beta).eps— numerical stability constant (e.g.1.0e-5).
Output shape and type match x.
Fused RMS normalisation (mlx::fast::rms_norm).
x— input tensor; normalised over the last axis.weight—{hidden}scale vector (same size as last axis ofx).eps— numerical stability constant (e.g.1.0e-6).
Output shape and type match x.
Fused rotary position embedding (mlx::fast::rope).
a— input{B, ..., T, D};...dims are passed through, butTmust be the second-to-last axis (e.g.{B, H, T, D}, heads-transposed) — seeEMLX.fast_rope/6's@docfor why a heads-axis placed beforeT(Bumblebee's{B, T, H, D}) silently miscomputes for more than one head (elixir-nx/emlx#121). Userope_with_positions/6orrope_with_freqs/6for that layout instead.dims— number of feature dims to rotate (≤ last-axis size, must be even).traditional—falsefor split-half (Qwen3);truefor interleaved.base— angular frequency base (e.g.10_000or1_000_000).scale— position scale (1.0unless using NTK-aware scaling).offset— integer position offset (tokens already in the KV cache).
traditional must match the model checkpoint's convention.
For Qwen3 (split-half): traditional: false.
Output shape and type match a.
Fused RoPE with precomputed inverse-frequency vector (mlx::fast::rope, freqs overload).
Use this variant when the model's RoPE scaling strategy produces a fixed
{dims/2} inv-frequency tensor that can be baked at graph-rewrite time
(e.g. :llama3 smooth-interpolation). Strategies that are seq-len conditional
or require cos/sin post-multiply (:linear, :dynamic, :longrope) should
use rope_with_positions/6 instead.
a— input{B, T, ..., D}(Bumblebee convention: heads NOT yet transposed)position_ids—{B, T}integer tensor. For decode (T = 1) the fast path usesposition_ids[b,0]as the per-batch offset intofreqs(same contract asmlx::fast::ropewith a scalar offset per batch). For prefill (T > 1) a per-token path runs so arbitrary positions (e.g. left-padded[0,…,0,1,2,…]) are correct; the offset-only entry point cannot represent that.dims— number of feature dims to rotate.traditional—falsefor split-half (Bumblebee / Qwen3);truefor interleaved.scale— position scale (1.0for most strategies with precomputed freqs).freqs—{dims/2}tensor of precomputed inverse frequencies.
Output shape and type match a.
Fused RoPE accepting a position_ids tensor (mlx::fast::rope, array-offset overload).
Use this variant when the calling convention provides position_ids as a tensor
(e.g. from Bumblebee's rotary embedding layer) rather than a scalar integer offset.
a— input{B, T, ..., D}(Bumblebee convention: heads NOT yet transposed)position_ids—{B, T}integer tensor; each row holds the token positions forone batch example. **Positions must be sequential within each row** (standard causal LM). The starting offset for batch item `b` is taken as `position_ids[b, 0]`; subsequent positions are inferred by MLX as `offset + 0, offset + 1, ...`.dims— number of feature dims to rotate.traditional—falsefor split-half (Bumblebee / Qwen3);truefor interleaved.base— angular frequency base (e.g.10_000).scale— position scale (1.0unless using NTK-aware scaling).
Output shape and type match a.
Sequential positions only (fast T=1 path)
For decode with T = 1 and base below about 1.0e5, the fast_rope_ids
opcode is used; it assumes sequential positions from position_ids[b, 0]. For
larger base (e.g. Qwen3 rope_theta 1M) or prefill (T > 1), the
per-token fast_rope_positions opcode is used, matching Bumblebee for
arbitrary per-token position_ids.
Flash-attention SDPA, no mask (mlx::fast::scaled_dot_product_attention).
GQA-native: k/v may have fewer heads than q — no pre-tiling required.
q—{B, N_q, T_q, D}k—{B, N_kv, T_kv, D}v—{B, N_kv, T_kv, D}scale— scalar (typically1 / sqrt(D))
Output: {B, N_q, T_q, D} — same dtype as q.
Softmax accumulates in float32 internally regardless of input dtype.
Flash-attention SDPA — either an additive/boolean mask tensor (5th arg),
or, with no mask, an opts keyword list (disambiguated by is_list/1):
:sinks— optional learned per-head attention-sink logits tensor, appended as an extra key/value pair the softmax normalises against (seemlx::fast::scaled_dot_product_attention'ssinksparameter). Shape must broadcast against{B, N_q}(typically{N_q}).
mask must be broadcast-compatible with {B, N_q, T_q, T_kv}.
Boolean false entries are masked out (-∞); float entries are added to
the pre-softmax scores.
For causal masking in decode (single query token), prefer the no-mask arity
since T_q=1 is always trivially causal.
Flash-attention SDPA with an additive/boolean mask and opts (:sinks
— see scaled_dot_product_attention/5).
Flash-attention SDPA with a built-in causal mask (mlx::fast::scaled_dot_product_attention,
mask_mode="causal").
MLX constructs the upper-triangular causal mask internally without materialising it,
making this equivalent to scaled_dot_product_attention/5 with a causal boolean mask
but cheaper: no mask tensor allocation, and the mask is fused into the Metal kernel.
GQA-native: k/v may have fewer heads than q — no pre-tiling required.
Input/output layout matches scaled_dot_product_attention/4:
q—{B, N_q, T_q, D}k—{B, N_kv, T_kv, D}v—{B, N_kv, T_kv, D}scale— pre-computed scalar (typically1 / sqrt(D))- Output —
{B, N_q, T_q, D}, same dtype asq
Causal flash-attention SDPA with opts (:sinks — see
scaled_dot_product_attention/5).
Causal SDPA with the key_mask check delegated to the C++ NIF (eager) or folded directly into the compiled graph (traced).
When eager, the NIF evaluates all(key_mask == 1):
- true (no padding, e.g. single-sequence decode) → pure causal SDPA, no mask tensor allocated.
- false (padded batch or multi-sequence) → builds a combined causal + key_mask additive mask and calls the masked SDPA kernel.
This avoids the Nx.cond double-evaluation problem: the NIF forces eval
of only the small {B, T_kv} key_mask subgraph, then branches in C++.
When traced, the compiled :fast_sdpa_causal_key_masked* opcode always
builds the combined causal+key_mask additive mask in-graph (a compiled
program can't branch on a runtime all(key_mask) check).
Input/output layout matches scaled_dot_product_attention_causal/4:
q—{B, N_q, T_q, D}k—{B, N_kv, T_kv, D}v—{B, N_kv, T_kv, D}scale— pre-computed scalarkey_mask—{B, T_kv}boolean/int tensor (1 = attend, 0 = masked)- Output —
{B, N_q, T_q, D}, same dtype asq
An optional 6th opts keyword list accepts :sinks (see
scaled_dot_product_attention/5).
Fused SwiGLU activation: silu(gate) * up where silu(x) = x * sigmoid(x).
Eliminates the two-op silu(gate_proj) * up_proj pattern that appears in
Qwen3's FFN layers (28× per decode step).
gate— gate-projection output; silu is applied element-wise.up— up-projection output; same shape asgate.
Output has the same shape and dtype as gate.