Skip to main content

mlx_native/ops/
kv_cache_copy.rs

1//! KV cache GPU copy dispatch.
2//!
3//! Copies new K or V data directly from a source GPU buffer into a
4//! pre-allocated KV cache buffer at the correct write position, with
5//! optional modulo wrapping for sliding window (ring buffer) caches.
6//!
7//! This eliminates the CPU round-trip that `append_bf16` requires:
8//! instead of GPU -> CPU (as_slice) -> CPU (copy loop) -> shared buffer,
9//! the GPU copies directly between two shared Metal buffers.
10
11use metal::MTLSize;
12
13use crate::buffer::MlxBuffer;
14use crate::encoder::CommandEncoder;
15use crate::error::{MlxError, Result};
16use crate::kernel_registry::KernelRegistry;
17
18use super::encode_helpers::{encode_with_args, KernelArg};
19
20/// MSL source for the KV cache copy kernel (embedded at compile time).
21pub static KV_CACHE_COPY_SHADER_SOURCE: &str = include_str!("../shaders/kv_cache_copy.metal");
22
23/// Register KV cache copy shader source with the given kernel registry.
24pub fn register(registry: &mut KernelRegistry) {
25    registry.register_source("kv_cache_copy", KV_CACHE_COPY_SHADER_SOURCE);
26}
27
28/// Dispatch a GPU copy from a source bf16 buffer into a KV cache buffer.
29///
30/// Both `src` and `cache` must be bf16 Metal buffers in shared memory.
31///
32/// # Arguments
33///
34/// * `encoder`   - Command encoder to record the dispatch into.
35/// * `registry`  - Kernel registry (must have kv_cache_copy registered).
36/// * `device`    - Metal device for pipeline compilation.
37/// * `src`       - Source buffer of shape `[n_new, row_size]` (bf16).
38/// * `cache`     - Destination cache buffer (bf16, pre-allocated).
39/// * `write_pos` - Starting write position in the cache (token index).
40/// * `row_size`  - Elements per token row (`n_kv_heads * head_dim`).
41/// * `n_new`     - Number of new tokens to copy.
42/// * `cache_cap` - Cache capacity (window size for sliding, max_seq_len for global).
43/// * `is_sliding`- Whether to use modulo wrapping (`true` for sliding window).
44///
45/// # Errors
46///
47/// Returns `MlxError::InvalidArgument` if parameters are inconsistent.
48#[allow(clippy::too_many_arguments)]
49pub fn dispatch_kv_cache_copy(
50    encoder: &mut CommandEncoder,
51    registry: &mut KernelRegistry,
52    device: &metal::DeviceRef,
53    src: &MlxBuffer,
54    cache: &MlxBuffer,
55    write_pos: u32,
56    row_size: u32,
57    n_new: u32,
58    cache_cap: u32,
59    is_sliding: bool,
60) -> Result<()> {
61    if n_new == 0 || row_size == 0 {
62        return Ok(()); // Nothing to copy
63    }
64
65    let total_elements = (n_new as u64) * (row_size as u64);
66    let src_elements = src.element_count() as u64;
67    if src_elements < total_elements {
68        return Err(MlxError::InvalidArgument(format!(
69            "kv_cache_copy: src has {} elements but need {} (n_new={} * row_size={})",
70            src_elements, total_elements, n_new, row_size
71        )));
72    }
73
74    // For global (non-sliding) caches, check we won't write past capacity
75    if !is_sliding && (write_pos as u64 + n_new as u64) > cache_cap as u64 {
76        return Err(MlxError::InvalidArgument(format!(
77            "kv_cache_copy: global cache overflow: write_pos({}) + n_new({}) > cache_cap({})",
78            write_pos, n_new, cache_cap
79        )));
80    }
81
82    let pipeline = registry.get_pipeline("kv_cache_copy", device)?;
83
84    let is_sliding_val: u32 = if is_sliding { 1 } else { 0 };
85
86    // Pass each scalar as individual set_bytes calls matching buffer indices 2-6
87    let write_pos_bytes = write_pos.to_ne_bytes();
88    let row_size_bytes = row_size.to_ne_bytes();
89    let n_new_bytes = n_new.to_ne_bytes();
90    let cache_cap_bytes = cache_cap.to_ne_bytes();
91    let is_sliding_bytes = is_sliding_val.to_ne_bytes();
92
93    encode_with_args(
94        encoder,
95        pipeline,
96        &[
97            (0, KernelArg::Buffer(src)),
98            (1, KernelArg::Buffer(cache)),
99            (2, KernelArg::Bytes(&write_pos_bytes)),
100            (3, KernelArg::Bytes(&row_size_bytes)),
101            (4, KernelArg::Bytes(&n_new_bytes)),
102            (5, KernelArg::Bytes(&cache_cap_bytes)),
103            (6, KernelArg::Bytes(&is_sliding_bytes)),
104        ],
105        MTLSize::new(total_elements, 1, 1),
106        MTLSize::new(std::cmp::min(256, total_elements), 1, 1),
107    );
108
109    Ok(())
110}
111
112/// Dispatch a batched GPU copy from a source f32 buffer into a f32 KV cache.
113///
114/// Copies ALL heads in one dispatch instead of one dispatch per head.
115///
116/// Source layout: `[n_heads * head_dim]` flat (one token, all heads).
117/// Cache layout: `[n_heads, capacity, head_dim]` head-major.
118///
119/// # Arguments
120///
121/// * `encoder`   - Command encoder to record the dispatch into.
122/// * `registry`  - Kernel registry (must have kv_cache_copy_batch_f32 registered).
123/// * `device`    - Metal device for pipeline compilation.
124/// * `src`       - Source buffer of shape `[n_heads * head_dim]` (f32).
125/// * `cache`     - Destination cache buffer (f32, pre-allocated).
126/// * `n_heads`   - Number of KV heads.
127/// * `head_dim`  - Elements per head.
128/// * `capacity`  - Cache capacity (window size or max_seq_len).
129/// * `seq_pos`   - Write position in cache (already wrapped for sliding).
130#[allow(clippy::too_many_arguments)]
131pub fn dispatch_kv_cache_copy_batch_f32(
132    encoder: &mut CommandEncoder,
133    registry: &mut KernelRegistry,
134    device: &metal::DeviceRef,
135    src: &MlxBuffer,
136    cache: &MlxBuffer,
137    n_heads: u32,
138    head_dim: u32,
139    capacity: u32,
140    seq_pos: u32,
141) -> Result<()> {
142    if n_heads == 0 || head_dim == 0 {
143        return Ok(());
144    }
145
146    let total_src = (n_heads as u64) * (head_dim as u64);
147    if (src.element_count() as u64) < total_src {
148        return Err(MlxError::InvalidArgument(format!(
149            "kv_cache_copy_batch_f32: src has {} elements but need {} (n_heads={} * head_dim={})",
150            src.element_count(), total_src, n_heads, head_dim
151        )));
152    }
153
154    let pipeline = registry.get_pipeline("kv_cache_copy_batch_f32", device)?;
155
156    let n_heads_bytes = n_heads.to_ne_bytes();
157    let head_dim_bytes = head_dim.to_ne_bytes();
158    let capacity_bytes = capacity.to_ne_bytes();
159    let seq_pos_bytes = seq_pos.to_ne_bytes();
160
161    use super::encode_helpers::{encode_with_args, KernelArg};
162
163    encode_with_args(
164        encoder,
165        pipeline,
166        &[
167            (0, KernelArg::Buffer(src)),
168            (1, KernelArg::Buffer(cache)),
169            (2, KernelArg::Bytes(&n_heads_bytes)),
170            (3, KernelArg::Bytes(&head_dim_bytes)),
171            (4, KernelArg::Bytes(&capacity_bytes)),
172            (5, KernelArg::Bytes(&seq_pos_bytes)),
173        ],
174        MTLSize::new(head_dim as u64, n_heads as u64, 1),
175        MTLSize::new(std::cmp::min(256, head_dim as u64), 1, 1),
176    );
177
178    Ok(())
179}
180
181/// Dispatch a GPU copy from a source f32 buffer into a f32 KV cache buffer.
182///
183/// Identical to `dispatch_kv_cache_copy` but for F32 data (used when the
184/// activation pipeline operates in F32 throughout).
185///
186/// Both `src` and `cache` must be f32 Metal buffers in shared memory.
187///
188/// # Arguments
189///
190/// * `encoder`   - Command encoder to record the dispatch into.
191/// * `registry`  - Kernel registry (must have kv_cache_copy_f32 registered).
192/// * `device`    - Metal device for pipeline compilation.
193/// * `src`       - Source buffer of shape `[n_new, row_size]` (f32).
194/// * `cache`     - Destination cache buffer (f32, pre-allocated).
195/// * `write_pos` - Starting write position in the cache (token index).
196/// * `row_size`  - Elements per token row (`n_kv_heads * head_dim`).
197/// * `n_new`     - Number of new tokens to copy.
198/// * `cache_cap` - Cache capacity (window size for sliding, max_seq_len for global).
199/// * `is_sliding`- Whether to use modulo wrapping (`true` for sliding window).
200///
201/// # Errors
202///
203/// Returns `MlxError::InvalidArgument` if parameters are inconsistent.
204#[allow(clippy::too_many_arguments)]
205pub fn dispatch_kv_cache_copy_f32(
206    encoder: &mut CommandEncoder,
207    registry: &mut KernelRegistry,
208    device: &metal::DeviceRef,
209    src: &MlxBuffer,
210    cache: &MlxBuffer,
211    write_pos: u32,
212    row_size: u32,
213    n_new: u32,
214    cache_cap: u32,
215    is_sliding: bool,
216) -> Result<()> {
217    if n_new == 0 || row_size == 0 {
218        return Ok(()); // Nothing to copy
219    }
220
221    let total_elements = (n_new as u64) * (row_size as u64);
222    let src_elements = src.element_count() as u64;
223    if src_elements < total_elements {
224        return Err(MlxError::InvalidArgument(format!(
225            "kv_cache_copy_f32: src has {} elements but need {} (n_new={} * row_size={})",
226            src_elements, total_elements, n_new, row_size
227        )));
228    }
229
230    // For global (non-sliding) caches, check we won't write past capacity
231    if !is_sliding && (write_pos as u64 + n_new as u64) > cache_cap as u64 {
232        return Err(MlxError::InvalidArgument(format!(
233            "kv_cache_copy_f32: global cache overflow: write_pos({}) + n_new({}) > cache_cap({})",
234            write_pos, n_new, cache_cap
235        )));
236    }
237
238    let pipeline = registry.get_pipeline("kv_cache_copy_f32", device)?;
239
240    let is_sliding_val: u32 = if is_sliding { 1 } else { 0 };
241
242    let write_pos_bytes = write_pos.to_ne_bytes();
243    let row_size_bytes = row_size.to_ne_bytes();
244    let n_new_bytes = n_new.to_ne_bytes();
245    let cache_cap_bytes = cache_cap.to_ne_bytes();
246    let is_sliding_bytes = is_sliding_val.to_ne_bytes();
247
248    encode_with_args(
249        encoder,
250        pipeline,
251        &[
252            (0, KernelArg::Buffer(src)),
253            (1, KernelArg::Buffer(cache)),
254            (2, KernelArg::Bytes(&write_pos_bytes)),
255            (3, KernelArg::Bytes(&row_size_bytes)),
256            (4, KernelArg::Bytes(&n_new_bytes)),
257            (5, KernelArg::Bytes(&cache_cap_bytes)),
258            (6, KernelArg::Bytes(&is_sliding_bytes)),
259        ],
260        MTLSize::new(total_elements, 1, 1),
261        MTLSize::new(std::cmp::min(256, total_elements), 1, 1),
262    );
263
264    Ok(())
265}
266
267/// Dispatch a batched F32→F16 copy from a source f32 buffer into an f16 KV cache.
268///
269/// Copies ALL heads in one dispatch, casting float→half on write.
270/// This halves KV cache memory bandwidth for SDPA reads (bandwidth-bound
271/// at batch=1 decode). Reference: llama.cpp stores KV cache in F16.
272///
273/// Source layout: `[n_heads * head_dim]` flat F32 (one token, all heads).
274/// Cache layout: `[n_heads, capacity, head_dim]` head-major F16.
275#[allow(clippy::too_many_arguments)]
276pub fn dispatch_kv_cache_copy_batch_f32_to_f16(
277    encoder: &mut CommandEncoder,
278    registry: &mut KernelRegistry,
279    device: &metal::DeviceRef,
280    src: &MlxBuffer,
281    cache: &MlxBuffer,
282    n_heads: u32,
283    head_dim: u32,
284    capacity: u32,
285    seq_pos: u32,
286) -> Result<()> {
287    if n_heads == 0 || head_dim == 0 {
288        return Ok(());
289    }
290
291    let total_src = (n_heads as u64) * (head_dim as u64);
292    if (src.element_count() as u64) < total_src {
293        return Err(MlxError::InvalidArgument(format!(
294            "kv_cache_copy_batch_f32_to_f16: src has {} elements but need {} (n_heads={} * head_dim={})",
295            src.element_count(), total_src, n_heads, head_dim
296        )));
297    }
298
299    let pipeline = registry.get_pipeline("kv_cache_copy_batch_f32_to_f16", device)?;
300
301    let n_heads_bytes = n_heads.to_ne_bytes();
302    let head_dim_bytes = head_dim.to_ne_bytes();
303    let capacity_bytes = capacity.to_ne_bytes();
304    let seq_pos_bytes = seq_pos.to_ne_bytes();
305
306    use super::encode_helpers::{encode_with_args, KernelArg};
307
308    encode_with_args(
309        encoder,
310        pipeline,
311        &[
312            (0, KernelArg::Buffer(src)),
313            (1, KernelArg::Buffer(cache)),
314            (2, KernelArg::Bytes(&n_heads_bytes)),
315            (3, KernelArg::Bytes(&head_dim_bytes)),
316            (4, KernelArg::Bytes(&capacity_bytes)),
317            (5, KernelArg::Bytes(&seq_pos_bytes)),
318        ],
319        MTLSize::new(head_dim as u64, n_heads as u64, 1),
320        MTLSize::new(std::cmp::min(256, head_dim as u64), 1, 1),
321    );
322
323    Ok(())
324}
325
326/// ADR-040 M4 — BATCHED multi-sequence F16-K copy: writes all `n_queries`
327/// decode queries' K into their own physical-slot regions of the shared
328/// multi_seq F16 cache in ONE dispatch (grid.z = N), replacing the per-slot
329/// host-side loop. `src` is `[N, n_heads*head_dim]` F32; `cache` is the FULL
330/// multi_seq buffer `[n_seqs, n_heads, capacity, head_dim]` F16; `slot_id`/
331/// `seq_pos` are `[N]` u32. Byte-identical to N single-slot calls.
332#[allow(clippy::too_many_arguments)]
333pub fn dispatch_kv_cache_copy_batch_f32_to_f16_batched(
334    encoder: &mut CommandEncoder,
335    registry: &mut KernelRegistry,
336    device: &metal::DeviceRef,
337    src: &MlxBuffer,
338    cache: &MlxBuffer,
339    slot_id: &MlxBuffer,
340    seq_pos: &MlxBuffer,
341    n_queries: u32,
342    n_heads: u32,
343    head_dim: u32,
344    capacity: u32,
345    is_ring: bool,
346) -> Result<()> {
347    if n_heads == 0 || head_dim == 0 || n_queries == 0 {
348        return Ok(());
349    }
350    let pipeline = registry.get_pipeline("kv_cache_copy_batch_f32_to_f16_batched", device)?;
351    let n_heads_bytes = n_heads.to_ne_bytes();
352    let head_dim_bytes = head_dim.to_ne_bytes();
353    let capacity_bytes = capacity.to_ne_bytes();
354    let is_ring_bytes = (if is_ring { 1u32 } else { 0u32 }).to_ne_bytes();
355
356    use super::encode_helpers::{encode_with_args, KernelArg};
357    encode_with_args(
358        encoder,
359        pipeline,
360        &[
361            (0, KernelArg::Buffer(src)),
362            (1, KernelArg::Buffer(cache)),
363            (2, KernelArg::Buffer(slot_id)),
364            (3, KernelArg::Buffer(seq_pos)),
365            (4, KernelArg::Bytes(&n_heads_bytes)),
366            (5, KernelArg::Bytes(&head_dim_bytes)),
367            (6, KernelArg::Bytes(&capacity_bytes)),
368            (7, KernelArg::Bytes(&is_ring_bytes)),
369        ],
370        MTLSize::new(head_dim as u64, n_heads as u64, n_queries as u64),
371        MTLSize::new(std::cmp::min(256, head_dim as u64), 1, 1),
372    );
373
374    Ok(())
375}
376
377/// Fused single-position K + V cache copy (F32 source → F32 cache) — DECODE shape.
378///
379/// ADR-028 iter-145: collapses the 2-dispatch pattern (1× K, 1× V) into a single
380/// dispatch. Saves one kernel launch floor (~14 µs/Apple GPU) per layer per token.
381/// At gemma4 30 layers, drops 60→30 KV-copy dispatches/decode-token.
382///
383/// Source layouts: `[n_heads * head_dim]` flat F32 each (one token, all heads).
384/// Cache layouts:  `[n_heads, capacity, head_dim]` head-major F32 each.
385///
386/// Each thread copies one (K, V) element pair at the same coords; results are
387/// byte-identical to two `dispatch_kv_cache_copy_batch_f32` calls.
388#[allow(clippy::too_many_arguments)]
389pub fn dispatch_kv_cache_copy_batch_f32_kv_dual(
390    encoder: &mut CommandEncoder,
391    registry: &mut KernelRegistry,
392    device: &metal::DeviceRef,
393    src_k: &MlxBuffer,
394    src_v: &MlxBuffer,
395    cache_k: &MlxBuffer,
396    cache_v: &MlxBuffer,
397    n_heads: u32,
398    head_dim: u32,
399    capacity: u32,
400    seq_pos: u32,
401) -> Result<()> {
402    if n_heads == 0 || head_dim == 0 {
403        return Ok(());
404    }
405
406    let total_src = (n_heads as u64) * (head_dim as u64);
407    if (src_k.element_count() as u64) < total_src {
408        return Err(MlxError::InvalidArgument(format!(
409            "kv_cache_copy_batch_f32_kv_dual: src_k has {} elements but need {} (n_heads={} * head_dim={})",
410            src_k.element_count(), total_src, n_heads, head_dim
411        )));
412    }
413    if (src_v.element_count() as u64) < total_src {
414        return Err(MlxError::InvalidArgument(format!(
415            "kv_cache_copy_batch_f32_kv_dual: src_v has {} elements but need {} (n_heads={} * head_dim={})",
416            src_v.element_count(), total_src, n_heads, head_dim
417        )));
418    }
419
420    let pipeline = registry.get_pipeline("kv_cache_copy_batch_f32_kv_dual", device)?;
421
422    let n_heads_bytes = n_heads.to_ne_bytes();
423    let head_dim_bytes = head_dim.to_ne_bytes();
424    let capacity_bytes = capacity.to_ne_bytes();
425    let seq_pos_bytes = seq_pos.to_ne_bytes();
426
427    encode_with_args(
428        encoder,
429        pipeline,
430        &[
431            (0, KernelArg::Buffer(src_k)),
432            (1, KernelArg::Buffer(src_v)),
433            (2, KernelArg::Buffer(cache_k)),
434            (3, KernelArg::Buffer(cache_v)),
435            (4, KernelArg::Bytes(&n_heads_bytes)),
436            (5, KernelArg::Bytes(&head_dim_bytes)),
437            (6, KernelArg::Bytes(&capacity_bytes)),
438            (7, KernelArg::Bytes(&seq_pos_bytes)),
439        ],
440        MTLSize::new(head_dim as u64, n_heads as u64, 1),
441        MTLSize::new(std::cmp::min(256, head_dim as u64), 1, 1),
442    );
443
444    Ok(())
445}
446
447/// Fused single-position K + V cache copy (F32 source → F16 cache) — DECODE shape.
448///
449/// Same as `dispatch_kv_cache_copy_batch_f32_kv_dual` but casts F32→F16 on write
450/// for the use_f16_kv branch. Halves SDPA-read bandwidth post-write.
451#[allow(clippy::too_many_arguments)]
452pub fn dispatch_kv_cache_copy_batch_f32_to_f16_kv_dual(
453    encoder: &mut CommandEncoder,
454    registry: &mut KernelRegistry,
455    device: &metal::DeviceRef,
456    src_k: &MlxBuffer,
457    src_v: &MlxBuffer,
458    cache_k: &MlxBuffer,
459    cache_v: &MlxBuffer,
460    n_heads: u32,
461    head_dim: u32,
462    capacity: u32,
463    seq_pos: u32,
464) -> Result<()> {
465    if n_heads == 0 || head_dim == 0 {
466        return Ok(());
467    }
468
469    let total_src = (n_heads as u64) * (head_dim as u64);
470    if (src_k.element_count() as u64) < total_src {
471        return Err(MlxError::InvalidArgument(format!(
472            "kv_cache_copy_batch_f32_to_f16_kv_dual: src_k has {} elements but need {} (n_heads={} * head_dim={})",
473            src_k.element_count(), total_src, n_heads, head_dim
474        )));
475    }
476    if (src_v.element_count() as u64) < total_src {
477        return Err(MlxError::InvalidArgument(format!(
478            "kv_cache_copy_batch_f32_to_f16_kv_dual: src_v has {} elements but need {} (n_heads={} * head_dim={})",
479            src_v.element_count(), total_src, n_heads, head_dim
480        )));
481    }
482
483    let pipeline = registry.get_pipeline("kv_cache_copy_batch_f32_to_f16_kv_dual", device)?;
484
485    let n_heads_bytes = n_heads.to_ne_bytes();
486    let head_dim_bytes = head_dim.to_ne_bytes();
487    let capacity_bytes = capacity.to_ne_bytes();
488    let seq_pos_bytes = seq_pos.to_ne_bytes();
489
490    encode_with_args(
491        encoder,
492        pipeline,
493        &[
494            (0, KernelArg::Buffer(src_k)),
495            (1, KernelArg::Buffer(src_v)),
496            (2, KernelArg::Buffer(cache_k)),
497            (3, KernelArg::Buffer(cache_v)),
498            (4, KernelArg::Bytes(&n_heads_bytes)),
499            (5, KernelArg::Bytes(&head_dim_bytes)),
500            (6, KernelArg::Bytes(&capacity_bytes)),
501            (7, KernelArg::Bytes(&seq_pos_bytes)),
502        ],
503        MTLSize::new(head_dim as u64, n_heads as u64, 1),
504        MTLSize::new(std::cmp::min(256, head_dim as u64), 1, 1),
505    );
506
507    Ok(())
508}
509
510/// Multi-position, all-heads KV cache copy (F32 → F32 cache, batched prefill).
511///
512/// Source layout: `[n_src_tokens, n_heads, head_dim]` (token-major). The
513/// kernel reads `[src_tok_offset, src_tok_offset + n_tokens)` from it.
514/// Cache layout:  `[n_heads, capacity, head_dim]` (head-major).
515/// Writes absolute positions `[seq_pos_start, seq_pos_start + n_tokens)` into
516/// cache slots `dst_pos % capacity`.
517///
518/// Global-layer contract: caller sets `seq_pos_start + n_tokens <= capacity`
519/// so `dst_pos % capacity == dst_pos` and writes are linear. Typical call:
520/// `src_tok_offset = 0`, `n_tokens = seq_len`, `seq_pos_start = 0`.
521///
522/// Sliding-window contract: caller sets `capacity = sliding_window`,
523/// `n_tokens = min(seq_len, capacity)`, `src_tok_offset = seq_len - n_tokens`,
524/// `seq_pos_start = seq_len - n_tokens`. This writes the last `n_tokens`
525/// source tokens into modular slots exactly once — no intra-dispatch race.
526/// Decode side reads via `ring_start = write_pos % capacity`.
527#[allow(clippy::too_many_arguments)]
528pub fn dispatch_kv_cache_copy_seq_f32(
529    encoder: &mut CommandEncoder,
530    registry: &mut KernelRegistry,
531    device: &metal::DeviceRef,
532    src: &MlxBuffer,
533    cache: &MlxBuffer,
534    n_heads: u32,
535    head_dim: u32,
536    capacity: u32,
537    seq_pos_start: u32,
538    n_tokens: u32,
539    src_tok_offset: u32,
540) -> Result<()> {
541    if n_heads == 0 || head_dim == 0 || n_tokens == 0 {
542        return Ok(());
543    }
544    let total_src = ((src_tok_offset as u64) + (n_tokens as u64))
545        * (n_heads as u64) * (head_dim as u64);
546    if (src.element_count() as u64) < total_src {
547        return Err(MlxError::InvalidArgument(format!(
548            "kv_cache_copy_seq_f32: src has {} elements, need {} ((src_tok_offset={} + n_tokens={}) * n_heads={} * head_dim={})",
549            src.element_count(), total_src, src_tok_offset, n_tokens, n_heads, head_dim
550        )));
551    }
552
553    let pipeline = registry.get_pipeline("kv_cache_copy_seq_f32", device)?;
554
555    let n_heads_bytes = n_heads.to_ne_bytes();
556    let head_dim_bytes = head_dim.to_ne_bytes();
557    let capacity_bytes = capacity.to_ne_bytes();
558    let seq_pos_start_bytes = seq_pos_start.to_ne_bytes();
559    let n_tokens_bytes = n_tokens.to_ne_bytes();
560    let src_tok_offset_bytes = src_tok_offset.to_ne_bytes();
561
562    use super::encode_helpers::{encode_with_args, KernelArg};
563
564    encode_with_args(
565        encoder,
566        pipeline,
567        &[
568            (0, KernelArg::Buffer(src)),
569            (1, KernelArg::Buffer(cache)),
570            (2, KernelArg::Bytes(&n_heads_bytes)),
571            (3, KernelArg::Bytes(&head_dim_bytes)),
572            (4, KernelArg::Bytes(&capacity_bytes)),
573            (5, KernelArg::Bytes(&seq_pos_start_bytes)),
574            (6, KernelArg::Bytes(&n_tokens_bytes)),
575            (7, KernelArg::Bytes(&src_tok_offset_bytes)),
576        ],
577        MTLSize::new(head_dim as u64, n_heads as u64, n_tokens as u64),
578        MTLSize::new(std::cmp::min(256, head_dim as u64), 1, 1),
579    );
580
581    Ok(())
582}
583
584/// Fused K + V cache copy (F32 source → F32 cache).  Wave P4.11.
585///
586/// Combines two `dispatch_kv_cache_copy_seq_f32` calls (one for K, one
587/// for V) into one dispatch.  Both streams share identical metadata
588/// (n_heads, head_dim, capacity, seq_pos_start, n_tokens,
589/// src_tok_offset) and are independently addressed in src/cache, so a
590/// single thread can copy one (K, V) element pair at the same
591/// coordinates.  Saves 1 dispatch per layer (30/prefill on Gemma 4).
592#[allow(clippy::too_many_arguments)]
593pub fn dispatch_kv_cache_copy_seq_f32_dual(
594    encoder: &mut CommandEncoder,
595    registry: &mut KernelRegistry,
596    device: &metal::DeviceRef,
597    src_k: &MlxBuffer,
598    src_v: &MlxBuffer,
599    cache_k: &MlxBuffer,
600    cache_v: &MlxBuffer,
601    n_heads: u32,
602    head_dim: u32,
603    capacity: u32,
604    seq_pos_start: u32,
605    n_tokens: u32,
606    src_tok_offset: u32,
607) -> Result<()> {
608    if n_heads == 0 || head_dim == 0 || n_tokens == 0 {
609        return Ok(());
610    }
611    let total_src = ((src_tok_offset as u64) + (n_tokens as u64))
612        * (n_heads as u64) * (head_dim as u64);
613    for (name, b) in [("src_k", src_k), ("src_v", src_v)] {
614        if (b.element_count() as u64) < total_src {
615            return Err(MlxError::InvalidArgument(format!(
616                "kv_cache_copy_seq_f32_dual: {} has {} elements, need {}",
617                name, b.element_count(), total_src
618            )));
619        }
620    }
621
622    let pipeline = registry.get_pipeline("kv_cache_copy_seq_f32_kv_dual", device)?;
623
624    let n_heads_bytes = n_heads.to_ne_bytes();
625    let head_dim_bytes = head_dim.to_ne_bytes();
626    let capacity_bytes = capacity.to_ne_bytes();
627    let seq_pos_start_bytes = seq_pos_start.to_ne_bytes();
628    let n_tokens_bytes = n_tokens.to_ne_bytes();
629    let src_tok_offset_bytes = src_tok_offset.to_ne_bytes();
630
631    use super::encode_helpers::{encode_with_args, KernelArg};
632
633    encode_with_args(
634        encoder,
635        pipeline,
636        &[
637            (0, KernelArg::Buffer(src_k)),
638            (1, KernelArg::Buffer(src_v)),
639            (2, KernelArg::Buffer(cache_k)),
640            (3, KernelArg::Buffer(cache_v)),
641            (4, KernelArg::Bytes(&n_heads_bytes)),
642            (5, KernelArg::Bytes(&head_dim_bytes)),
643            (6, KernelArg::Bytes(&capacity_bytes)),
644            (7, KernelArg::Bytes(&seq_pos_start_bytes)),
645            (8, KernelArg::Bytes(&n_tokens_bytes)),
646            (9, KernelArg::Bytes(&src_tok_offset_bytes)),
647        ],
648        MTLSize::new(head_dim as u64, n_heads as u64, n_tokens as u64),
649        MTLSize::new(std::cmp::min(256, head_dim as u64), 1, 1),
650    );
651
652    Ok(())
653}
654
655/// Fused K + V cache copy (F32 source → F16 cache).  Wave P4.11
656/// f16-cache variant of `dispatch_kv_cache_copy_seq_f32_dual`.
657#[allow(clippy::too_many_arguments)]
658pub fn dispatch_kv_cache_copy_seq_f32_to_f16_dual(
659    encoder: &mut CommandEncoder,
660    registry: &mut KernelRegistry,
661    device: &metal::DeviceRef,
662    src_k: &MlxBuffer,
663    src_v: &MlxBuffer,
664    cache_k: &MlxBuffer,
665    cache_v: &MlxBuffer,
666    n_heads: u32,
667    head_dim: u32,
668    capacity: u32,
669    seq_pos_start: u32,
670    n_tokens: u32,
671    src_tok_offset: u32,
672) -> Result<()> {
673    if n_heads == 0 || head_dim == 0 || n_tokens == 0 {
674        return Ok(());
675    }
676    let total_src = ((src_tok_offset as u64) + (n_tokens as u64))
677        * (n_heads as u64) * (head_dim as u64);
678    for (name, b) in [("src_k", src_k), ("src_v", src_v)] {
679        if (b.element_count() as u64) < total_src {
680            return Err(MlxError::InvalidArgument(format!(
681                "kv_cache_copy_seq_f32_to_f16_dual: {} has {} elements, need {}",
682                name, b.element_count(), total_src
683            )));
684        }
685    }
686
687    let pipeline = registry.get_pipeline("kv_cache_copy_seq_f32_to_f16_kv_dual", device)?;
688
689    let n_heads_bytes = n_heads.to_ne_bytes();
690    let head_dim_bytes = head_dim.to_ne_bytes();
691    let capacity_bytes = capacity.to_ne_bytes();
692    let seq_pos_start_bytes = seq_pos_start.to_ne_bytes();
693    let n_tokens_bytes = n_tokens.to_ne_bytes();
694    let src_tok_offset_bytes = src_tok_offset.to_ne_bytes();
695
696    use super::encode_helpers::{encode_with_args, KernelArg};
697
698    encode_with_args(
699        encoder,
700        pipeline,
701        &[
702            (0, KernelArg::Buffer(src_k)),
703            (1, KernelArg::Buffer(src_v)),
704            (2, KernelArg::Buffer(cache_k)),
705            (3, KernelArg::Buffer(cache_v)),
706            (4, KernelArg::Bytes(&n_heads_bytes)),
707            (5, KernelArg::Bytes(&head_dim_bytes)),
708            (6, KernelArg::Bytes(&capacity_bytes)),
709            (7, KernelArg::Bytes(&seq_pos_start_bytes)),
710            (8, KernelArg::Bytes(&n_tokens_bytes)),
711            (9, KernelArg::Bytes(&src_tok_offset_bytes)),
712        ],
713        MTLSize::new(head_dim as u64, n_heads as u64, n_tokens as u64),
714        MTLSize::new(std::cmp::min(256, head_dim as u64), 1, 1),
715    );
716
717    Ok(())
718}
719
720/// Multi-position, all-heads KV cache copy (BF16 source → F32 cache, batched prefill).
721///
722/// Same layout and semantics as [`dispatch_kv_cache_copy_seq_f32`] — including
723/// `src_tok_offset` source slicing and `dst_pos % capacity` ring-wrap for
724/// sliding-window layers — but reads bfloat16 from the source and promotes to
725/// float32 on write.
726///
727/// Used in the Phase 2 bf16 activation path where `pf_k_normed` / `pf_v_normed`
728/// become bf16, but the KV cache (used by decode SDPA) stays f32.
729///
730/// Source layout: `[n_src_tokens, n_heads, head_dim]` bf16.
731/// Cache layout:  `[n_heads, capacity, head_dim]`     f32.
732#[allow(clippy::too_many_arguments)]
733pub fn dispatch_kv_cache_copy_seq_bf16(
734    encoder: &mut CommandEncoder,
735    registry: &mut KernelRegistry,
736    device: &metal::DeviceRef,
737    src: &MlxBuffer,
738    cache: &MlxBuffer,
739    n_heads: u32,
740    head_dim: u32,
741    capacity: u32,
742    seq_pos_start: u32,
743    n_tokens: u32,
744    src_tok_offset: u32,
745) -> Result<()> {
746    if n_heads == 0 || head_dim == 0 || n_tokens == 0 {
747        return Ok(());
748    }
749    // src is bf16 (2 bytes per element)
750    let total_src = ((src_tok_offset as u64) + (n_tokens as u64))
751        * (n_heads as u64) * (head_dim as u64);
752    let src_bytes_needed = total_src * 2; // bf16 = 2 bytes
753    if (src.byte_len() as u64) < src_bytes_needed {
754        return Err(MlxError::InvalidArgument(format!(
755            "kv_cache_copy_seq_bf16: src has {} bytes, need {} ((src_tok_offset={} + n_tokens={}) * n_heads={} * head_dim={} * 2)",
756            src.byte_len(), src_bytes_needed, src_tok_offset, n_tokens, n_heads, head_dim
757        )));
758    }
759
760    let pipeline = registry.get_pipeline("kv_cache_copy_seq_bf16", device)?;
761
762    let n_heads_bytes = n_heads.to_ne_bytes();
763    let head_dim_bytes = head_dim.to_ne_bytes();
764    let capacity_bytes = capacity.to_ne_bytes();
765    let seq_pos_start_bytes = seq_pos_start.to_ne_bytes();
766    let n_tokens_bytes = n_tokens.to_ne_bytes();
767    let src_tok_offset_bytes = src_tok_offset.to_ne_bytes();
768
769    use super::encode_helpers::{encode_with_args, KernelArg};
770
771    encode_with_args(
772        encoder,
773        pipeline,
774        &[
775            (0, KernelArg::Buffer(src)),
776            (1, KernelArg::Buffer(cache)),
777            (2, KernelArg::Bytes(&n_heads_bytes)),
778            (3, KernelArg::Bytes(&head_dim_bytes)),
779            (4, KernelArg::Bytes(&capacity_bytes)),
780            (5, KernelArg::Bytes(&seq_pos_start_bytes)),
781            (6, KernelArg::Bytes(&n_tokens_bytes)),
782            (7, KernelArg::Bytes(&src_tok_offset_bytes)),
783        ],
784        MTLSize::new(head_dim as u64, n_heads as u64, n_tokens as u64),
785        MTLSize::new(std::cmp::min(256, head_dim as u64), 1, 1),
786    );
787
788    Ok(())
789}
790
791/// Multi-position, all-heads KV cache copy (F32 source → F16 cache, batched prefill).
792///
793/// Same semantics as [`dispatch_kv_cache_copy_seq_f32`] (including
794/// `src_tok_offset` source slicing and `dst_pos % capacity` ring-wrap for
795/// sliding-window layers) but writes half-precision values in the cache.
796#[allow(clippy::too_many_arguments)]
797pub fn dispatch_kv_cache_copy_seq_f32_to_f16(
798    encoder: &mut CommandEncoder,
799    registry: &mut KernelRegistry,
800    device: &metal::DeviceRef,
801    src: &MlxBuffer,
802    cache: &MlxBuffer,
803    n_heads: u32,
804    head_dim: u32,
805    capacity: u32,
806    seq_pos_start: u32,
807    n_tokens: u32,
808    src_tok_offset: u32,
809) -> Result<()> {
810    if n_heads == 0 || head_dim == 0 || n_tokens == 0 {
811        return Ok(());
812    }
813    let total_src = ((src_tok_offset as u64) + (n_tokens as u64))
814        * (n_heads as u64) * (head_dim as u64);
815    if (src.element_count() as u64) < total_src {
816        return Err(MlxError::InvalidArgument(format!(
817            "kv_cache_copy_seq_f32_to_f16: src has {} elements, need {}",
818            src.element_count(), total_src
819        )));
820    }
821
822    let pipeline = registry.get_pipeline("kv_cache_copy_seq_f32_to_f16", device)?;
823
824    let n_heads_bytes = n_heads.to_ne_bytes();
825    let head_dim_bytes = head_dim.to_ne_bytes();
826    let capacity_bytes = capacity.to_ne_bytes();
827    let seq_pos_start_bytes = seq_pos_start.to_ne_bytes();
828    let n_tokens_bytes = n_tokens.to_ne_bytes();
829    let src_tok_offset_bytes = src_tok_offset.to_ne_bytes();
830
831    use super::encode_helpers::{encode_with_args, KernelArg};
832
833    encode_with_args(
834        encoder,
835        pipeline,
836        &[
837            (0, KernelArg::Buffer(src)),
838            (1, KernelArg::Buffer(cache)),
839            (2, KernelArg::Bytes(&n_heads_bytes)),
840            (3, KernelArg::Bytes(&head_dim_bytes)),
841            (4, KernelArg::Bytes(&capacity_bytes)),
842            (5, KernelArg::Bytes(&seq_pos_start_bytes)),
843            (6, KernelArg::Bytes(&n_tokens_bytes)),
844            (7, KernelArg::Bytes(&src_tok_offset_bytes)),
845        ],
846        MTLSize::new(head_dim as u64, n_heads as u64, n_tokens as u64),
847        MTLSize::new(std::cmp::min(256, head_dim as u64), 1, 1),
848    );
849
850    Ok(())
851}
852
853/// ADR-030 iter-95: bit-exact BF16→BF16 strided cache copy from pf_k_perm
854/// (head-major BF16) to bf16_xlen_cache (head-major BF16).
855///
856/// Used by the DFlash spec-decode xlen verify path to persist BF16 K/V
857/// across rounds without the F16-intermediate precision drift that
858/// iter-92/93 root-caused for Option A's non-toy coherence failures.
859/// Bit-identical to what Option C reads from pf_k_perm in the same call
860/// (single rounding at head_norm_rope's F32→BF16 output).
861///
862/// `src` layout: `[n_heads, src_seq_len, head_dim]` BF16 head-major
863/// (matches fused_head_norm_rope's bf16 permuted output `pf_k_perm` /
864/// `pf_v_perm`).
865/// `cache` layout: `[n_heads, capacity, head_dim]` BF16 head-major.
866#[allow(clippy::too_many_arguments)]
867pub fn dispatch_kv_cache_copy_seq_bf16_to_bf16_head_major(
868    encoder: &mut CommandEncoder,
869    registry: &mut KernelRegistry,
870    device: &metal::DeviceRef,
871    src: &MlxBuffer,
872    cache: &MlxBuffer,
873    n_heads: u32,
874    head_dim: u32,
875    capacity: u32,
876    seq_pos_start: u32,
877    n_tokens: u32,
878    src_tok_offset: u32,
879    src_seq_len: u32,
880) -> Result<()> {
881    if n_heads == 0 || head_dim == 0 || n_tokens == 0 {
882        return Ok(());
883    }
884    let total_src = (n_heads as u64) * (src_seq_len as u64) * (head_dim as u64);
885    if (src.element_count() as u64) < total_src {
886        return Err(MlxError::InvalidArgument(format!(
887            "kv_cache_copy_seq_bf16_to_bf16_head_major: src has {} elements, need {} \
888             ({} heads × {} src_seq_len × {} head_dim)",
889            src.element_count(), total_src, n_heads, src_seq_len, head_dim
890        )));
891    }
892    if src.dtype() != crate::DType::BF16 {
893        return Err(MlxError::InvalidArgument(format!(
894            "kv_cache_copy_seq_bf16_to_bf16_head_major: src must be BF16, got {:?}",
895            src.dtype()
896        )));
897    }
898    if cache.dtype() != crate::DType::BF16 {
899        return Err(MlxError::InvalidArgument(format!(
900            "kv_cache_copy_seq_bf16_to_bf16_head_major: cache must be BF16, got {:?}",
901            cache.dtype()
902        )));
903    }
904
905    let pipeline = registry.get_pipeline("kv_cache_copy_seq_bf16_to_bf16_head_major", device)?;
906
907    let n_heads_bytes = n_heads.to_ne_bytes();
908    let head_dim_bytes = head_dim.to_ne_bytes();
909    let capacity_bytes = capacity.to_ne_bytes();
910    let seq_pos_start_bytes = seq_pos_start.to_ne_bytes();
911    let n_tokens_bytes = n_tokens.to_ne_bytes();
912    let src_tok_offset_bytes = src_tok_offset.to_ne_bytes();
913    let src_seq_len_bytes = src_seq_len.to_ne_bytes();
914
915    use super::encode_helpers::{encode_with_args, KernelArg};
916
917    encode_with_args(
918        encoder,
919        pipeline,
920        &[
921            (0, KernelArg::Buffer(src)),
922            (1, KernelArg::Buffer(cache)),
923            (2, KernelArg::Bytes(&n_heads_bytes)),
924            (3, KernelArg::Bytes(&head_dim_bytes)),
925            (4, KernelArg::Bytes(&capacity_bytes)),
926            (5, KernelArg::Bytes(&seq_pos_start_bytes)),
927            (6, KernelArg::Bytes(&n_tokens_bytes)),
928            (7, KernelArg::Bytes(&src_tok_offset_bytes)),
929            (8, KernelArg::Bytes(&src_seq_len_bytes)),
930        ],
931        MTLSize::new(head_dim as u64, n_heads as u64, n_tokens as u64),
932        MTLSize::new(std::cmp::min(256, head_dim as u64), 1, 1),
933    );
934
935    Ok(())
936}