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