Skip to main content

llama_cpp_4/
llama_batch_ext.rs

1//! Safe wrapper around `llama_batch_ext`, llama.cpp's entry-wise batch.
2//!
3//! [`LlamaBatch`](crate::llama_batch::LlamaBatch) wraps the original
4//! `llama_batch`: six parallel arrays, one entry per token, and a single flat
5//! `embd` block for the whole batch. That shape cannot describe a batch where
6//! different entries carry differently-shaped inputs, which is what multimodal
7//! and speculative decoding need. `llama_batch_ext`
8//! ([llama.cpp #24669](https://github.com/ggml-org/llama.cpp/pull/24669))
9//! replaces the arrays with a list of entries that each own their token id,
10//! positions, input embedding and "state" embedding.
11//!
12//! Reach for this instead of [`LlamaBatch`](crate::llama_batch::LlamaBatch)
13//! when you need one of the things only an entry can express:
14//!
15//! - **an input embedding on some entries but not others** — a vision encoder's
16//!   output interleaved with text tokens, where `llama_batch` forces the whole
17//!   batch to be one or the other;
18//! - **M-RoPE positions**, which are `[t, y, x, z]` per embedding entry rather
19//!   than one scalar — see [`LlamaBatchExt::set_positions`];
20//! - **a state embedding** (`llama_batch_ext_set_embd_state`), the hidden state
21//!   an MTP draft context reads from the target model.
22//!
23//! For plain token decoding there is nothing to gain:
24//! [`LlamaBatch`](crate::llama_batch::LlamaBatch) is the same work with less
25//! ceremony, and `llama_decode` is where this crate's decode lifecycle hooks
26//! live — see [`LlamaContext::process`](crate::context::LlamaContext::process).
27//!
28//! # Example
29//!
30//! ```no_run
31//! use llama_cpp_4::context::LlamaContext;
32//! use llama_cpp_4::llama_batch_ext::{LlamaBatchExt, LlamaProcessType};
33//! use llama_cpp_4::token::LlamaToken;
34//!
35//! # fn f(ctx: &mut LlamaContext<'_>, tokens: &[LlamaToken])
36//! #     -> Result<(), Box<dyn std::error::Error>> {
37//! let mut batch = LlamaBatchExt::new(ctx)?;
38//!
39//! let mut pos = 0;
40//! let mut last = None;
41//! for &token in tokens {
42//!     let entry = batch.add_token(token, 0)?;
43//!     batch.set_pos(entry, pos)?;
44//!     pos += 1;
45//!     last = Some(entry);
46//! }
47//! if let Some(entry) = last {
48//!     batch.set_output(entry, true)?;
49//! }
50//!
51//! ctx.process(&mut batch, LlamaProcessType::Decode)?;
52//! # Ok(())
53//! # }
54//! ```
55
56// Every `unsafe` block below is justified in a `SAFETY:` comment; keep it that
57// way. See the note on the lint in the workspace `Cargo.toml`.
58#![deny(clippy::undocumented_unsafe_blocks)]
59
60use std::ptr::NonNull;
61
62use llama_cpp_sys_4::{llama_pos, llama_seq_id};
63
64use crate::context::LlamaContext;
65use crate::token::LlamaToken;
66
67/// Positions an embedding entry carries on an M-RoPE or interleaved-M-RoPE
68/// model: `[t, y, x, z]`, where `t` doubles as the KV-cache position.
69///
70/// Mirrors `GGML_MROPE_SECTIONS`. A model with any other `RoPE` type uses a
71/// single position per entry; [`LlamaBatchExt::n_pos_per_embd`] reports which
72/// applies.
73pub const MROPE_SECTIONS: usize = llama_cpp_sys_4::GGML_MROPE_SECTIONS as usize;
74
75/// A handle to one entry of a [`LlamaBatchExt`], returned by the `add_*`
76/// methods and accepted by the `set_*` methods.
77///
78/// This is the entry's index in the batch. It is a distinct type because every
79/// other `i32` in this module — token ids, sequence ids, positions — is also an
80/// `i32`, and mixing them up is otherwise a silent error rather than a
81/// compile error.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
83pub struct BatchEntry(i32);
84
85impl BatchEntry {
86    /// The entry's index in the batch, which is also the index to pass to
87    /// [`LlamaContext::get_logits_ith`](crate::context::LlamaContext::get_logits_ith)
88    /// after processing.
89    #[must_use]
90    pub fn index(self) -> i32 {
91        self.0
92    }
93}
94
95impl From<BatchEntry> for i32 {
96    fn from(entry: BatchEntry) -> Self {
97        entry.0
98    }
99}
100
101/// A borrowed, row-major block of embedding values, mirroring `llama_embd`.
102///
103/// llama.cpp copies the values out when the view is handed to the batch, so the
104/// borrow only has to outlive the call.
105#[derive(Debug, Clone, Copy)]
106pub struct EmbeddingRows<'a> {
107    data: &'a [f32],
108    n_rows: usize,
109    n_embd: usize,
110}
111
112impl<'a> EmbeddingRows<'a> {
113    /// A `n_rows` × `n_embd` block.
114    ///
115    /// # Errors
116    ///
117    /// Returns [`BatchExtError::EmbeddingShape`] unless
118    /// `n_rows * n_embd == data.len()`. llama.cpp takes the shape on trust and
119    /// reads `n_rows * n_embd` floats, so a wrong shape would read out of
120    /// bounds.
121    pub fn new(data: &'a [f32], n_rows: usize, n_embd: usize) -> Result<Self, BatchExtError> {
122        if n_rows.checked_mul(n_embd) != Some(data.len()) {
123            return Err(BatchExtError::EmbeddingShape {
124                n_rows,
125                n_embd,
126                len: data.len(),
127            });
128        }
129        Ok(Self {
130            data,
131            n_rows,
132            n_embd,
133        })
134    }
135
136    /// A single row of `data.len()` values — the common case.
137    #[must_use]
138    pub fn row(data: &'a [f32]) -> Self {
139        Self {
140            data,
141            n_rows: 1,
142            n_embd: data.len(),
143        }
144    }
145
146    /// Number of rows in the block.
147    #[must_use]
148    pub fn n_rows(&self) -> usize {
149        self.n_rows
150    }
151
152    /// Width of one row.
153    #[must_use]
154    pub fn n_embd(&self) -> usize {
155        self.n_embd
156    }
157
158    /// Total values in the block — the width llama.cpp fixes per batch.
159    fn total(self) -> usize {
160        self.data.len()
161    }
162
163    fn as_raw(self) -> llama_cpp_sys_4::llama_embd {
164        llama_cpp_sys_4::llama_embd {
165            data: self.data.as_ptr(),
166            n_rows: self.n_rows,
167            n_embd: self.n_embd,
168        }
169    }
170}
171
172/// Which graph to run a batch through.
173///
174/// Mirrors `llama_process_type`.
175//
176// Cast the sys constants to `u32` so the discriminants compile on both clang
177// (where bindgen emits `c_uint`, making the cast a no-op) and MSVC (where it
178// emits `c_int` and the cast is required).
179#[allow(clippy::unnecessary_cast)]
180#[repr(u32)]
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182pub enum LlamaProcessType {
183    /// Run the encoder — the equivalent of `llama_encode`.
184    Encode = llama_cpp_sys_4::LLAMA_PROCESS_TYPE_ENCODE as u32,
185    /// Run the decoder — the equivalent of `llama_decode`.
186    Decode = llama_cpp_sys_4::LLAMA_PROCESS_TYPE_DECODE as u32,
187}
188
189impl From<LlamaProcessType> for llama_cpp_sys_4::llama_process_type {
190    fn from(value: LlamaProcessType) -> Self {
191        value as u32 as Self
192    }
193}
194
195/// What a joint decision head (`clef` and friends) reads an entry as.
196///
197/// Mirrors `llama_decision_order`
198/// ([llama.cpp #29831](https://github.com/ggml-org/llama.cpp/pull/29831)). A
199/// run of entries sharing a value is one span, spans are separated by entries
200/// marked [`Self::None`], and an option belongs to the last question before it.
201/// Only decision models read this; everything else leaves it at
202/// [`Self::None`].
203#[allow(clippy::unnecessary_cast)]
204#[repr(u32)]
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
206pub enum LlamaDecisionOrder {
207    /// Not read by the decision head. The default.
208    #[default]
209    None = llama_cpp_sys_4::LLAMA_DECISION_ORDER_NONE as u32,
210    /// Text of a question.
211    QuestionNoul = llama_cpp_sys_4::LLAMA_DECISION_ORDER_QUESTION_NOUL as u32,
212    /// Question asking for a choice between the options that follow.
213    QuestionChoice = llama_cpp_sys_4::LLAMA_DECISION_ORDER_QUESTION_CHOICE as u32,
214    /// Question asking for a score.
215    QuestionScore = llama_cpp_sys_4::LLAMA_DECISION_ORDER_QUESTION_SCORE as u32,
216    /// Text of an option belonging to the preceding question.
217    Option = llama_cpp_sys_4::LLAMA_DECISION_ORDER_OPTION as u32,
218}
219
220impl From<LlamaDecisionOrder> for llama_cpp_sys_4::llama_decision_order {
221    fn from(value: LlamaDecisionOrder) -> Self {
222        value as u32 as Self
223    }
224}
225
226/// Errors from building a [`LlamaBatchExt`].
227///
228/// llama.cpp reports most of these as a bare `false` with the detail in its
229/// log, so the variants carry whatever the Rust side can establish on its own.
230#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
231pub enum BatchExtError {
232    /// `llama_batch_ext_init` returned null.
233    #[error("llama.cpp returned null from llama_batch_ext_init")]
234    NullReturn,
235    /// The batch already holds `n_batch` entries.
236    #[error("batch is full at {0} entries (the context's n_batch)")]
237    Full(u32),
238    /// The token id is negative or not below the model's vocabulary size.
239    #[error("token id {0} is out of range for this model's vocabulary")]
240    InvalidToken(i32),
241    /// The sequence id is negative or not below the context's `n_seq_max`.
242    #[error("sequence id {seq_id} is out of range (n_seq_max = {n_seq_max})")]
243    InvalidSeqId {
244        /// The rejected sequence id.
245        seq_id: llama_seq_id,
246        /// The context's `n_seq_max`.
247        n_seq_max: u32,
248    },
249    /// `n_rows * n_embd` did not match the length of the value buffer.
250    #[error("embedding shape {n_rows} x {n_embd} does not match a buffer of {len} values")]
251    EmbeddingShape {
252        /// Rows the caller declared.
253        n_rows: usize,
254        /// Row width the caller declared.
255        n_embd: usize,
256        /// Values actually supplied.
257        len: usize,
258    },
259    /// The entry handle does not belong to this batch — it came from another
260    /// batch, or from this one before [`LlamaBatchExt::clear`].
261    #[error("entry {entry} is not in this batch ({len} entries)")]
262    NoSuchEntry {
263        /// The rejected entry index.
264        entry: i32,
265        /// Entries currently in the batch.
266        len: usize,
267    },
268    /// The wrong number of positions was supplied for the entry. A token entry
269    /// takes one; an embedding entry takes
270    /// [`LlamaBatchExt::n_pos_per_embd`].
271    #[error("entry {entry} needs exactly {expected} position(s), got {got}")]
272    PositionArity {
273        /// The entry being positioned.
274        entry: i32,
275        /// Positions the entry requires.
276        expected: usize,
277        /// Positions supplied.
278        got: usize,
279    },
280    /// Every embedding in one batch must have the same total width, fixed by
281    /// the first one accepted.
282    #[error("this batch's embeddings are {expected} values wide, got {got}")]
283    EmbeddingWidthMismatch {
284        /// Width fixed by the first embedding in the batch.
285        expected: usize,
286        /// Width supplied.
287        got: usize,
288    },
289    /// The entry already carries an input embedding; llama.cpp allows one.
290    #[error("entry {0} already has an input embedding")]
291    EmbeddingAlreadySet(i32),
292    /// The entry already carries a state embedding; llama.cpp allows one.
293    #[error("entry {0} already has a state embedding")]
294    StateAlreadySet(i32),
295    /// llama.cpp rejected an embedding: the row width matches neither the
296    /// decoder nor the encoder input width, or (for a state embedding) this
297    /// context takes none — only an MTP context does. The specific reason is in
298    /// the llama.cpp log.
299    ///
300    /// When this comes from [`LlamaBatchExt::add_embedding`], llama.cpp has
301    /// already appended the entry by the time it validates the block, and
302    /// offers no way to remove one — so the batch is left holding an entry with
303    /// no input, and [`LlamaBatchExt::clear`] is the only way forward. See
304    /// [`LlamaBatchExt::has_incomplete_entry`].
305    #[error("llama.cpp rejected the embedding (see the llama.cpp log for the reason)")]
306    EmbeddingRejected,
307}
308
309/// What a batch entry carries so far.
310///
311/// This mirrors the state `llama_batch_ext` keeps per entry, because two things
312/// the C API takes on trust depend on it: the number of positions
313/// `llama_batch_ext_set_pos` reads off the caller's pointer, and whether the
314/// entry has an input at all.
315#[derive(Debug, Clone, Copy, PartialEq, Eq)]
316struct Entry {
317    /// Carries a token id, so it takes one position rather than
318    /// `n_pos_per_embd`.
319    has_token: bool,
320    /// Carries an input embedding.
321    has_embd: bool,
322    /// Carries a state embedding.
323    has_state: bool,
324}
325
326impl Entry {
327    fn token() -> Self {
328        Self {
329            has_token: true,
330            has_embd: false,
331            has_state: false,
332        }
333    }
334
335    fn embedding() -> Self {
336        Self {
337            has_token: false,
338            has_embd: true,
339            has_state: false,
340        }
341    }
342
343    /// An entry with no input at all: `add_empty` that was never filled in, or
344    /// an `add_embedding` whose block llama.cpp rejected after appending the
345    /// entry. Processing one is not meaningful.
346    fn empty() -> Self {
347        Self {
348            has_token: false,
349            has_embd: false,
350            has_state: false,
351        }
352    }
353
354    fn is_incomplete(self) -> bool {
355        !self.has_token && !self.has_embd
356    }
357
358    /// Positions `llama_batch_ext_set_pos` will read for this entry.
359    fn n_pos(self, n_pos_per_embd: usize) -> usize {
360        if self.has_token {
361            1
362        } else {
363            n_pos_per_embd
364        }
365    }
366}
367
368/// A safe wrapper around `llama_batch_ext`.
369///
370/// Build it with [`Self::new`], add entries, configure each entry through the
371/// [`BatchEntry`] handle its `add_*` returned, then hand it to
372/// [`LlamaContext::process`](crate::context::LlamaContext::process).
373///
374/// # Positions are not implied
375///
376/// Unlike [`LlamaBatch::add`](crate::llama_batch::LlamaBatch::add), adding an
377/// entry does *not* take its position — upstream leaves it undefined and
378/// expects an explicit [`Self::set_pos`]. The underlying entry starts at
379/// position `0`, so a forgotten call is not a type error. Set the position for
380/// every entry.
381///
382/// # Context binding
383///
384/// `llama_batch_ext_init` reads the context's batch size, sequence count and
385/// model geometry, and keeps a pointer to its memory module for position
386/// validation. The batch is therefore only usable with the context that built
387/// it: [`LlamaContext::process`](crate::context::LlamaContext::process) checks
388/// that and refuses anything else, so a batch outliving its context cannot be
389/// processed by a later one.
390#[derive(Debug)]
391pub struct LlamaBatchExt {
392    raw: NonNull<llama_cpp_sys_4::llama_batch_ext>,
393    /// The context that built this batch, checked by `process`. See
394    /// `LlamaContext::id`.
395    pub(crate) ctx_id: u64,
396    /// One per entry, in upstream's order. See [`Entry`].
397    entries: Vec<Entry>,
398    /// Entries with output enabled. Mirrors
399    /// [`LlamaBatch::initialized_logits`](crate::llama_batch::LlamaBatch) so
400    /// the context can validate `get_logits_ith` after processing.
401    pub(crate) initialized_logits: Vec<i32>,
402    /// Total width of this batch's embeddings, fixed by the first one accepted.
403    embd_width: Option<usize>,
404    n_pos_per_embd: usize,
405    n_batch: u32,
406    n_seq_max: u32,
407    n_vocab: i32,
408}
409
410// SAFETY: the batch is a plain owned buffer of entries. Nothing inside it is
411// shared with another thread, and every method that mutates it takes `&mut
412// self`. The context pointer it keeps internally is only dereferenced by
413// `llama_process`, which this crate reaches only through `&mut LlamaContext`.
414unsafe impl Send for LlamaBatchExt {}
415
416impl LlamaBatchExt {
417    /// Build a batch for `ctx`.
418    ///
419    /// Capacity is the context's `n_batch`; the sequence ids it will accept are
420    /// `0..n_seq_max`. Both are read from the context here, so they cannot
421    /// drift from it later.
422    ///
423    /// # Errors
424    ///
425    /// Returns [`BatchExtError::NullReturn`] if llama.cpp could not allocate.
426    pub fn new(ctx: &LlamaContext<'_>) -> Result<Self, BatchExtError> {
427        // SAFETY: `ctx.context` is a `NonNull` held by a live `&LlamaContext`.
428        // `llama_batch_ext_init` only reads the context (batch size, sequence
429        // count, model geometry) and returns an owned batch or null.
430        let raw = unsafe { llama_cpp_sys_4::llama_batch_ext_init(ctx.context.as_ptr()) };
431        let raw = NonNull::new(raw).ok_or(BatchExtError::NullReturn)?;
432
433        // Mirrors `llama_hparams::n_pos_per_embd()`, which the batch uses to
434        // decide how many positions to read for an embedding entry.
435        let rope_type = ctx.model.rope_type();
436        let n_pos_per_embd = if rope_type == llama_cpp_sys_4::LLAMA_ROPE_TYPE_MROPE
437            || rope_type == llama_cpp_sys_4::LLAMA_ROPE_TYPE_IMROPE
438        {
439            MROPE_SECTIONS
440        } else {
441            1
442        };
443
444        Ok(Self {
445            raw,
446            ctx_id: ctx.id(),
447            entries: Vec::new(),
448            initialized_logits: Vec::new(),
449            embd_width: None,
450            n_pos_per_embd,
451            n_batch: ctx.n_batch(),
452            n_seq_max: ctx.n_seq_max(),
453            n_vocab: ctx.model.n_vocab(),
454        })
455    }
456
457    /// Drop every entry, keeping the allocation for the next round.
458    ///
459    /// [`BatchEntry`] handles from before the call are no longer valid; the
460    /// `set_*` methods reject them rather than writing to a recycled entry.
461    pub fn clear(&mut self) {
462        // SAFETY: `self.raw` is live for as long as `self` — only `Drop` frees
463        // it — and `clear` just truncates the batch's own vectors.
464        unsafe { llama_cpp_sys_4::llama_batch_ext_clear(self.raw.as_ptr()) }
465        self.entries.clear();
466        self.initialized_logits.clear();
467        self.embd_width = None;
468    }
469
470    /// Number of entries in the batch.
471    #[must_use]
472    pub fn len(&self) -> usize {
473        self.entries.len()
474    }
475
476    /// Whether the batch has no entries.
477    #[must_use]
478    pub fn is_empty(&self) -> bool {
479        self.entries.is_empty()
480    }
481
482    /// Whether any entry carries no input — neither a token id nor an input
483    /// embedding — which makes the batch unprocessable.
484    ///
485    /// Two things leave one behind: [`Self::add_empty`] that was never filled
486    /// in with [`Self::set_token_embedding`], and an [`Self::add_embedding`]
487    /// whose block llama.cpp rejected *after* appending the entry. There is no
488    /// upstream call to remove an entry, so the way out is [`Self::clear`].
489    /// [`LlamaContext::process`](crate::context::LlamaContext::process) checks
490    /// this rather than submitting a batch with a hole in it.
491    #[must_use]
492    pub fn has_incomplete_entry(&self) -> bool {
493        self.entries.iter().any(|entry| entry.is_incomplete())
494    }
495
496    /// Index of the first entry carrying no input, if any.
497    pub(crate) fn first_incomplete_entry(&self) -> Option<i32> {
498        let idx = self
499            .entries
500            .iter()
501            .position(|entry| entry.is_incomplete())?;
502        i32::try_from(idx).ok()
503    }
504
505    /// Maximum entries this batch can hold — the context's `n_batch`.
506    #[must_use]
507    pub fn capacity(&self) -> u32 {
508        self.n_batch
509    }
510
511    /// Positions an embedding entry in this batch requires:
512    /// [`MROPE_SECTIONS`] on an M-RoPE model, `1` otherwise. A token entry
513    /// always takes one.
514    #[must_use]
515    pub fn n_pos_per_embd(&self) -> usize {
516        self.n_pos_per_embd
517    }
518
519    /// Entries with output enabled, in the order they were enabled.
520    #[must_use]
521    pub fn outputs(&self) -> &[i32] {
522        &self.initialized_logits
523    }
524
525    /// Add a token entry for sequence `seq_id`.
526    ///
527    /// Set its position with [`Self::set_pos`]; upstream leaves it undefined.
528    ///
529    /// # Errors
530    ///
531    /// [`BatchExtError::Full`] when the batch is at capacity,
532    /// [`BatchExtError::InvalidSeqId`] for an out-of-range sequence, or
533    /// [`BatchExtError::InvalidToken`] for a token id outside the vocabulary.
534    pub fn add_token(
535        &mut self,
536        LlamaToken(id): LlamaToken,
537        seq_id: llama_seq_id,
538    ) -> Result<BatchEntry, BatchExtError> {
539        // Checked here rather than left to upstream: `llama_batch_ext_add_token`
540        // appends the entry and *then* validates the id, so a bad id would
541        // leave an entry with no input behind.
542        if id < 0 || id >= self.n_vocab {
543            return Err(BatchExtError::InvalidToken(id));
544        }
545        let idx =
546            // SAFETY: `self.raw` is live, and `id` was range-checked against
547            // `n_vocab` just above so the call cannot take the post-append
548            // rejection path. `seq_id` is validated by upstream, which returns
549            // `-3` rather than misbehaving.
550            unsafe { llama_cpp_sys_4::llama_batch_ext_add_token(self.raw.as_ptr(), seq_id, id) };
551        self.finish_add(idx, seq_id, Entry::token(), BatchExtError::InvalidToken(id))
552    }
553
554    /// Add an embedding-only entry for sequence `seq_id`.
555    ///
556    /// The row width must match the model's decoder input width
557    /// (`n_embd_inp`) or its encoder input width, and every embedding in one
558    /// batch must have the same width.
559    ///
560    /// Positions come from [`Self::set_positions`]: an embedding entry takes
561    /// [`Self::n_pos_per_embd`] of them, not one.
562    ///
563    /// # Errors
564    ///
565    /// [`BatchExtError::Full`], [`BatchExtError::InvalidSeqId`],
566    /// [`BatchExtError::EmbeddingWidthMismatch`] if it disagrees with the
567    /// batch's established width, or [`BatchExtError::EmbeddingRejected`] if
568    /// llama.cpp would not take the block.
569    ///
570    /// `EmbeddingRejected` here leaves an entry with no input in the batch —
571    /// upstream appends before it validates, and offers no way to remove one.
572    /// See [`Self::has_incomplete_entry`].
573    pub fn add_embedding(
574        &mut self,
575        embd: EmbeddingRows<'_>,
576        seq_id: llama_seq_id,
577    ) -> Result<BatchEntry, BatchExtError> {
578        self.check_embd_width(embd)?;
579        // SAFETY: `self.raw` is live. `embd.as_raw()` describes a block whose
580        // `n_rows * n_embd` equals the length of the borrowed slice —
581        // `EmbeddingRows::new` enforces that — so llama.cpp reads exactly the
582        // values it is given, and it copies them before returning.
583        let idx = unsafe {
584            llama_cpp_sys_4::llama_batch_ext_add_embd(self.raw.as_ptr(), seq_id, embd.as_raw())
585        };
586        let entry = self.finish_add(
587            idx,
588            seq_id,
589            Entry::embedding(),
590            BatchExtError::EmbeddingRejected,
591        );
592        if entry.is_ok() {
593            self.embd_width = Some(embd.total());
594        }
595        entry
596    }
597
598    /// Add an entry with neither a token id nor an embedding, to be filled in
599    /// with [`Self::set_token_embedding`].
600    ///
601    /// Treated as an embedding entry for positioning: it takes
602    /// [`Self::n_pos_per_embd`] positions.
603    ///
604    /// Until it is filled in, the entry counts as incomplete and
605    /// [`LlamaContext::process`](crate::context::LlamaContext::process) will
606    /// refuse the batch — see [`Self::has_incomplete_entry`].
607    ///
608    /// # Errors
609    ///
610    /// [`BatchExtError::Full`] or [`BatchExtError::InvalidSeqId`].
611    pub fn add_empty(&mut self, seq_id: llama_seq_id) -> Result<BatchEntry, BatchExtError> {
612        // SAFETY: `self.raw` is live; the call takes only scalars besides it.
613        let idx = unsafe { llama_cpp_sys_4::llama_batch_ext_add(self.raw.as_ptr(), seq_id) };
614        // `llama_batch_ext_add` has no -2 case; any negative is -1 or -3.
615        self.finish_add(
616            idx,
617            seq_id,
618            Entry::empty(),
619            BatchExtError::Full(self.n_batch),
620        )
621    }
622
623    /// Map the `i32` an `add_*` returned onto a [`BatchEntry`] or an error, and
624    /// record the entry's kind.
625    ///
626    /// Upstream's codes are `-1` full, `-2` the input was rejected (what that
627    /// means depends on which `add_*` was called, hence `rejected`), `-3`
628    /// invalid sequence id.
629    fn finish_add(
630        &mut self,
631        idx: i32,
632        seq_id: llama_seq_id,
633        entry: Entry,
634        rejected: BatchExtError,
635    ) -> Result<BatchEntry, BatchExtError> {
636        match idx {
637            // `-1` (full) and `-3` (bad sequence id) are raised before the
638            // entry is appended, so there is nothing to mirror.
639            -1 => Err(BatchExtError::Full(self.n_batch)),
640            -3 => Err(BatchExtError::InvalidSeqId {
641                seq_id,
642                n_seq_max: self.n_seq_max,
643            }),
644            // `-2` is raised *after* it was appended — the input was rejected,
645            // not the entry. Mirror it anyway, or every later index would be
646            // off by one against upstream's.
647            -2 => {
648                self.entries.push(Entry::empty());
649                Err(rejected)
650            }
651            idx if idx < 0 => Err(rejected),
652            idx => {
653                // `add_*` appends, so the new entry is always at `len()`. Keep
654                // the mirror in step even if upstream ever renumbers.
655                debug_assert_eq!(usize::try_from(idx).ok(), Some(self.entries.len()));
656                self.entries.push(entry);
657                Ok(BatchEntry(idx))
658            }
659        }
660    }
661
662    /// Also feed this entry to `seq_id`. The entry keeps the sequence it was
663    /// added with.
664    ///
665    /// # Errors
666    ///
667    /// [`BatchExtError::NoSuchEntry`] for a stale handle, or
668    /// [`BatchExtError::InvalidSeqId`] for an out-of-range sequence.
669    pub fn add_seq(
670        &mut self,
671        entry: BatchEntry,
672        seq_id: llama_seq_id,
673    ) -> Result<(), BatchExtError> {
674        self.check_entry(entry)?;
675        let ok =
676            // SAFETY: `self.raw` is live and `check_entry` above proved
677            // `entry.0` indexes an entry that exists in this batch.
678            unsafe { llama_cpp_sys_4::llama_batch_ext_add_seq(self.raw.as_ptr(), entry.0, seq_id) };
679        if ok {
680            Ok(())
681        } else {
682            Err(BatchExtError::InvalidSeqId {
683                seq_id,
684                n_seq_max: self.n_seq_max,
685            })
686        }
687    }
688
689    /// Set the position of a token entry.
690    ///
691    /// # Errors
692    ///
693    /// [`BatchExtError::NoSuchEntry`] for a stale handle, or
694    /// [`BatchExtError::PositionArity`] if the entry is an embedding entry on
695    /// an M-RoPE model and needs [`Self::n_pos_per_embd`] positions — use
696    /// [`Self::set_positions`] for those.
697    pub fn set_pos(&mut self, entry: BatchEntry, pos: llama_pos) -> Result<(), BatchExtError> {
698        self.set_positions(entry, &[pos])
699    }
700
701    /// Set all positions of an entry.
702    ///
703    /// `pos` must hold exactly the number of positions the entry takes: one for
704    /// a token entry, [`Self::n_pos_per_embd`] for an embedding entry. On an
705    /// M-RoPE model that is `[t, y, x, z]`, where `t` is also the KV-cache
706    /// position.
707    ///
708    /// # Errors
709    ///
710    /// [`BatchExtError::NoSuchEntry`] for a stale handle, or
711    /// [`BatchExtError::PositionArity`] for the wrong number of positions —
712    /// llama.cpp reads the arity it expects straight off the pointer, so a
713    /// short slice would read out of bounds.
714    pub fn set_positions(
715        &mut self,
716        entry: BatchEntry,
717        pos: &[llama_pos],
718    ) -> Result<(), BatchExtError> {
719        let expected = self.check_entry(entry)?.n_pos(self.n_pos_per_embd);
720        if pos.len() != expected {
721            return Err(BatchExtError::PositionArity {
722                entry: entry.0,
723                expected,
724                got: pos.len(),
725            });
726        }
727        // SAFETY: `self.raw` is live and `check_entry` proved `entry.0` exists.
728        // The critical part is the arity: upstream reads one `llama_pos` for a
729        // token entry and `n_pos_per_embd` for an embedding one, straight off
730        // this pointer, and the length check above proved `pos` holds exactly
731        // that many.
732        let ok = unsafe {
733            llama_cpp_sys_4::llama_batch_ext_set_pos(self.raw.as_ptr(), entry.0, pos.as_ptr())
734        };
735        debug_assert!(ok, "set_pos rejected an entry that check_entry accepted");
736        Ok(())
737    }
738
739    /// Attach an input embedding to an entry that already has a token id, so
740    /// the entry carries both.
741    ///
742    /// # Errors
743    ///
744    /// [`BatchExtError::NoSuchEntry`] for a stale handle,
745    /// [`BatchExtError::EmbeddingAlreadySet`] if the entry has one,
746    /// [`BatchExtError::EmbeddingWidthMismatch`] if it disagrees with the
747    /// batch's established width, or [`BatchExtError::EmbeddingRejected`] if
748    /// llama.cpp would not take the block.
749    pub fn set_token_embedding(
750        &mut self,
751        entry: BatchEntry,
752        embd: EmbeddingRows<'_>,
753    ) -> Result<(), BatchExtError> {
754        if self.check_entry(entry)?.has_embd {
755            return Err(BatchExtError::EmbeddingAlreadySet(entry.0));
756        }
757        self.check_embd_width(embd)?;
758        // SAFETY: `self.raw` is live, `check_entry` proved `entry.0` exists,
759        // and `EmbeddingRows` guarantees the block's declared shape matches the
760        // slice behind it. llama.cpp copies the values out before returning.
761        let ok = unsafe {
762            llama_cpp_sys_4::llama_batch_ext_set_embd_token(
763                self.raw.as_ptr(),
764                entry.0,
765                embd.as_raw(),
766            )
767        };
768        if !ok {
769            return Err(BatchExtError::EmbeddingRejected);
770        }
771        self.embd_width = Some(embd.total());
772        self.mark(entry, |slot| slot.has_embd = true);
773        Ok(())
774    }
775
776    /// Attach a *state* embedding to an entry — hidden state carried over from
777    /// an earlier stage, such as the target model's layers for MTP or a vision
778    /// encoder's deepstack output.
779    ///
780    /// # Errors
781    ///
782    /// [`BatchExtError::NoSuchEntry`] for a stale handle,
783    /// [`BatchExtError::StateAlreadySet`] if the entry has one, or
784    /// [`BatchExtError::EmbeddingRejected`] when the width is wrong or this
785    /// context takes no state embedding at all — only an MTP context does, so
786    /// check
787    /// [`LlamaContext::context_type`](crate::context::LlamaContext::context_type).
788    pub fn set_state_embedding(
789        &mut self,
790        entry: BatchEntry,
791        state: EmbeddingRows<'_>,
792    ) -> Result<(), BatchExtError> {
793        if self.check_entry(entry)?.has_state {
794            return Err(BatchExtError::StateAlreadySet(entry.0));
795        }
796        // SAFETY: as for `set_token_embedding`. A context that takes no state
797        // embedding is reported by upstream as `false`, not misread.
798        let ok = unsafe {
799            llama_cpp_sys_4::llama_batch_ext_set_embd_state(
800                self.raw.as_ptr(),
801                entry.0,
802                state.as_raw(),
803            )
804        };
805        if !ok {
806            return Err(BatchExtError::EmbeddingRejected);
807        }
808        self.mark(entry, |slot| slot.has_state = true);
809        Ok(())
810    }
811
812    /// Enable or disable output for an entry, making its logits and embeddings
813    /// readable after processing.
814    ///
815    /// Upstream exposes this twice — `llama_batch_ext_set_output_logits` and
816    /// `llama_batch_ext_set_output_embd` — and the two call the same setter, so
817    /// there is one method here. Should they ever diverge, this one keeps the
818    /// logits meaning.
819    ///
820    /// # Errors
821    ///
822    /// [`BatchExtError::NoSuchEntry`] for a stale handle.
823    pub fn set_output(&mut self, entry: BatchEntry, value: bool) -> Result<(), BatchExtError> {
824        self.check_entry(entry)?;
825        // SAFETY: `self.raw` is live and `check_entry` proved `entry.0` exists;
826        // the rest of the call is a `bool`.
827        let ok = unsafe {
828            llama_cpp_sys_4::llama_batch_ext_set_output_logits(self.raw.as_ptr(), entry.0, value)
829        };
830        debug_assert!(ok, "set_output rejected an entry that check_entry accepted");
831
832        if value {
833            if !self.initialized_logits.contains(&entry.0) {
834                self.initialized_logits.push(entry.0);
835            }
836        } else {
837            self.initialized_logits.retain(|i| *i != entry.0);
838        }
839        Ok(())
840    }
841
842    /// Mark what a joint decision head should read this entry as.
843    ///
844    /// Only decision models (`clef` and friends) look at this; see
845    /// [`LlamaDecisionOrder`].
846    ///
847    /// # Errors
848    ///
849    /// [`BatchExtError::NoSuchEntry`] for a stale handle.
850    pub fn set_decision_order(
851        &mut self,
852        entry: BatchEntry,
853        order: LlamaDecisionOrder,
854    ) -> Result<(), BatchExtError> {
855        self.check_entry(entry)?;
856        // SAFETY: `self.raw` is live, `check_entry` proved `entry.0` exists,
857        // and `order.into()` can only produce one of the `llama_decision_order`
858        // discriminants.
859        let ok = unsafe {
860            llama_cpp_sys_4::llama_batch_ext_set_decision_order(
861                self.raw.as_ptr(),
862                entry.0,
863                order.into(),
864            )
865        };
866        debug_assert!(
867            ok,
868            "set_decision_order rejected an entry that check_entry accepted"
869        );
870        Ok(())
871    }
872
873    /// Validate an entry handle against this batch, returning its state.
874    fn check_entry(&self, entry: BatchEntry) -> Result<Entry, BatchExtError> {
875        usize::try_from(entry.0)
876            .ok()
877            .and_then(|idx| self.entries.get(idx))
878            .copied()
879            .ok_or(BatchExtError::NoSuchEntry {
880                entry: entry.0,
881                len: self.entries.len(),
882            })
883    }
884
885    /// Reject an embedding that disagrees with the width the batch settled on,
886    /// which upstream would otherwise reject with the detail only in its log.
887    fn check_embd_width(&self, embd: EmbeddingRows<'_>) -> Result<(), BatchExtError> {
888        match self.embd_width {
889            Some(expected) if expected != embd.total() => {
890                Err(BatchExtError::EmbeddingWidthMismatch {
891                    expected,
892                    got: embd.total(),
893                })
894            }
895            _ => Ok(()),
896        }
897    }
898
899    /// Record that `entry` gained an input or state embedding.
900    fn mark(&mut self, entry: BatchEntry, update: impl FnOnce(&mut Entry)) {
901        if let Ok(idx) = usize::try_from(entry.0) {
902            if let Some(slot) = self.entries.get_mut(idx) {
903                update(slot);
904            }
905        }
906    }
907
908    pub(crate) fn as_ptr(&mut self) -> *mut llama_cpp_sys_4::llama_batch_ext {
909        self.raw.as_ptr()
910    }
911}
912
913impl Drop for LlamaBatchExt {
914    fn drop(&mut self) {
915        // `llama_batch_ext` has no destructor beyond freeing its own vectors —
916        // it never dereferences the context pointer it holds — so this is safe
917        // even once the originating context is gone.
918        // SAFETY: `self.raw` came from `llama_batch_ext_init` in `new` and is
919        // freed exactly once, here; `LlamaBatchExt` is neither `Copy` nor
920        // `Clone`. As the comment above notes, the destructor never touches
921        // the context pointer the batch holds, so this is sound even once that
922        // context is gone.
923        unsafe { llama_cpp_sys_4::llama_batch_ext_free(self.raw.as_ptr()) }
924    }
925}
926
927#[cfg(test)]
928mod tests {
929    use super::*;
930
931    #[test]
932    fn embedding_rows_rejects_a_shape_that_does_not_match_the_buffer() {
933        let data = [0.0_f32; 6];
934        assert_eq!(
935            EmbeddingRows::new(&data, 2, 4).unwrap_err(),
936            BatchExtError::EmbeddingShape {
937                n_rows: 2,
938                n_embd: 4,
939                len: 6
940            }
941        );
942        let rows = EmbeddingRows::new(&data, 2, 3).unwrap();
943        assert_eq!((rows.n_rows(), rows.n_embd()), (2, 3));
944    }
945
946    #[test]
947    fn embedding_rows_row_is_one_row_of_everything() {
948        let data = [1.0_f32, 2.0, 3.0];
949        let rows = EmbeddingRows::row(&data);
950        assert_eq!((rows.n_rows(), rows.n_embd()), (1, 3));
951    }
952
953    /// `n_rows * n_embd` must not be allowed to wrap into a length that happens
954    /// to match — that would hand llama.cpp a pointer with a bogus extent.
955    #[test]
956    fn embedding_rows_shape_does_not_wrap() {
957        let data = [0.0_f32; 1];
958        assert!(EmbeddingRows::new(&data, usize::MAX, 2).is_err());
959    }
960
961    #[test]
962    fn mrope_sections_matches_upstream() {
963        assert_eq!(MROPE_SECTIONS, 4);
964    }
965
966    #[test]
967    fn process_type_maps_onto_the_sys_constants() {
968        assert_eq!(
969            llama_cpp_sys_4::llama_process_type::from(LlamaProcessType::Encode),
970            llama_cpp_sys_4::LLAMA_PROCESS_TYPE_ENCODE
971        );
972        assert_eq!(
973            llama_cpp_sys_4::llama_process_type::from(LlamaProcessType::Decode),
974            llama_cpp_sys_4::LLAMA_PROCESS_TYPE_DECODE
975        );
976    }
977
978    #[test]
979    fn decision_order_maps_onto_the_sys_constants() {
980        assert_eq!(
981            llama_cpp_sys_4::llama_decision_order::from(LlamaDecisionOrder::None),
982            llama_cpp_sys_4::LLAMA_DECISION_ORDER_NONE
983        );
984        assert_eq!(
985            llama_cpp_sys_4::llama_decision_order::from(LlamaDecisionOrder::Option),
986            llama_cpp_sys_4::LLAMA_DECISION_ORDER_OPTION
987        );
988    }
989}