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}