@@ -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