llama_cpp_4/context/tensor_capture.rs
1//! Capture intermediate tensor outputs during [`crate::LlamaContext::decode`].
2//!
3//! llama.cpp builds a computation graph for each forward pass. Every node has a
4//! string name — for transformer blocks the layer output is typically
5//! `"l_out-{N}"` (e.g. `"l_out-13"`), attention norms are `"attn_norm-{N}"`, and
6//! the final norm is `"result_norm"`.
7//!
8//! The graph evaluation callback (`cb_eval`) runs in two phases for each node:
9//!
10//! | Phase | `ask` | Behaviour |
11//! |---|---|---|
12//! | Ask | `true` | Return `true` to request a copy of this tensor's data. |
13//! | Data | `false` | Tensor is computed; data is copied via `ggml_backend_tensor_get`. |
14//!
15//! [`TensorCapture`] implements that callback and stores matching tensors in a
16//! [`HashMap`] you can read after `decode()` finishes.
17//!
18//! # Typical use cases
19//!
20//! - **Layer probing** — inspect hidden states at specific depths.
21//! - **EAGLE / distillation** — read draft-model anchor layers (see `examples/eagle`).
22//! - **Debugging** — dump norms or attention outputs with [`TensorCapture::for_prefix`].
23//!
24//! # Setup
25//!
26//! 1. Build a [`TensorCapture`] with the filter you need ([`TensorCapture::for_layers`]
27//! is the common case).
28//! 2. Pass it to the legacy unsafe
29//! [`LlamaContextParams::with_tensor_capture`](crate::LlamaContextParams::with_tensor_capture).
30//! The capture must remain at a stable address and outlive the
31//! [`LlamaContext`](crate::LlamaContext). Prefer the owned, checked
32//! [`TensorTransactions`](crate::TensorTransactions) API in new code.
33//! 3. Run [`LlamaContext::decode`](crate::LlamaContext::decode) as usual.
34//! 4. Read [`CapturedTensor`] values via [`TensorCapture::get_layer`],
35//! [`TensorCapture::get`], or [`TensorCapture::iter`].
36//!
37//! Call [`TensorCapture::clear`](crate::TensorCapture::clear) before reusing the same capture on another batch.
38//!
39//! # Example
40//!
41//! ```no_run
42//! use llama_cpp_4::prelude::*;
43//! use std::num::NonZeroU32;
44//!
45//! fn main() {
46//! let backend = LlamaBackend::init().unwrap();
47//! let model = LlamaModel::load_from_file(
48//! &backend,
49//! "model.gguf",
50//! &LlamaModelParams::default(),
51//! )
52//! .unwrap();
53//!
54//! let mut capture = TensorCapture::for_layers(&[13, 20, 27]);
55//! let ctx_params = unsafe {
56//! LlamaContextParams::default()
57//! .with_n_ctx(NonZeroU32::new(512))
58//! .with_tensor_capture(&mut capture)
59//! };
60//! let mut ctx = model.new_context(&backend, ctx_params).unwrap();
61//!
62//! let tokens = model.str_to_token("Hello", AddBos::Always).unwrap();
63//! let mut batch = LlamaBatch::new(512, 1);
64//! for (i, &tok) in tokens.iter().enumerate() {
65//! batch
66//! .add(tok, i as i32, &[0], i == tokens.len() - 1)
67//! .unwrap();
68//! }
69//! ctx.decode(&mut batch).unwrap();
70//!
71//! for &layer in &[13, 20, 27] {
72//! if let Some(t) = capture.get_layer(layer) {
73//! println!(
74//! "l_out-{layer}: {} tokens × {} dims",
75//! t.n_tokens(),
76//! t.n_embd()
77//! );
78//! if let Some(vec) = t.token_embedding(0) {
79//! println!(" first token, first 3 dims: {:?}", &vec[..3.min(vec.len())]);
80//! }
81//! }
82//! }
83//! }
84//! ```
85//!
86//! # Tensor layout
87//!
88//! Each [`CapturedTensor`] stores a flat `f32` buffer with
89//! `data[token_idx * n_embd + dim_idx]` (ggml row-major: `ne0` = embedding dim,
90//! `ne1` = token count). Use [`CapturedTensor::token_embedding`] to slice one row.
91
92use std::collections::HashMap;
93
94/// A single tensor copied out of the decode graph.
95///
96/// Produced by [`TensorCapture`] after a successful [`crate::LlamaContext::decode`].
97/// For layer outputs (`"l_out-N"`), [`Self::layer`] is set to `N`.
98#[derive(Debug, Clone)]
99pub struct CapturedTensor {
100 /// Graph node name (e.g. `"l_out-13"`, `"result_norm"`).
101 pub name: String,
102 /// Layer index when `name` is `"l_out-{N}"`, otherwise `None`.
103 pub layer: Option<usize>,
104 /// First dimension (typically `n_embd` / hidden size).
105 pub ne0: usize,
106 /// Second dimension (typically number of tokens in the batch position).
107 pub ne1: usize,
108 /// Flattened `ne0 * ne1` values in ggml row-major order.
109 ///
110 /// Index as `data[token_idx * ne0 + dim_idx]`.
111 pub data: Vec<f32>,
112}
113
114impl CapturedTensor {
115 /// Number of embedding dimensions (alias for [`Self::ne0`]).
116 #[inline]
117 #[must_use]
118 pub fn n_embd(&self) -> usize {
119 self.ne0
120 }
121
122 /// Number of token positions (alias for [`Self::ne1`]).
123 #[inline]
124 #[must_use]
125 pub fn n_tokens(&self) -> usize {
126 self.ne1
127 }
128
129 /// Hidden-state vector for one token index.
130 ///
131 /// Returns `None` when `token_idx >= n_tokens()`.
132 #[must_use]
133 pub fn token_embedding(&self, token_idx: usize) -> Option<&[f32]> {
134 if token_idx >= self.ne1 {
135 return None;
136 }
137 let start = token_idx * self.ne0;
138 Some(&self.data[start..start + self.ne0])
139 }
140}
141
142/// Strategy for selecting which tensors to capture.
143#[derive(Debug, Clone)]
144enum CaptureFilter {
145 /// `"l_out-{N}"` for each listed layer index `N`.
146 Layers(Vec<usize>),
147 /// Exact graph node names.
148 Names(Vec<String>),
149 /// Names starting with a prefix (e.g. `"attn_out"`).
150 Prefix(String),
151 /// Every node (can be very large — debug only).
152 All,
153}
154
155/// Captures intermediate tensors during [`crate::LlamaContext::decode`].
156///
157/// Attach with the legacy unsafe
158/// [`LlamaContextParams::with_tensor_capture`](crate::LlamaContextParams::with_tensor_capture)
159/// before creating the context. The same instance can be reused across decodes
160/// if you call [`Self::clear`] between passes.
161///
162/// # Lifetime
163///
164/// The capture must remain at a stable address and outlive the
165/// [`crate::LlamaContext`] it is wired into. This requirement is not enforced
166/// by the returned params; callers must uphold the unsafe constructor's
167/// contract. Prefer [`crate::TensorTransactions`], whose state is pinned and
168/// owned by the context.
169pub struct TensorCapture {
170 filter: CaptureFilter,
171 captured: HashMap<String, CapturedTensor>,
172}
173
174impl std::fmt::Debug for TensorCapture {
175 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176 f.debug_struct("TensorCapture")
177 .field("filter", &self.filter)
178 .field("captured_count", &self.captured.len())
179 .field("captured_keys", &self.captured.keys().collect::<Vec<_>>())
180 .finish()
181 }
182}
183
184impl TensorCapture {
185 /// Capture transformer layer outputs `"l_out-{N}"` for the given indices.
186 ///
187 /// This is the usual choice for hidden-state extraction. EAGLE-3 draft models
188 /// often use three layers at ~50%, 75%, and 100% depth — e.g. `[13, 20, 27]`
189 /// on a 28-layer model.
190 #[must_use]
191 pub fn for_layers(layer_indices: &[usize]) -> Self {
192 Self {
193 filter: CaptureFilter::Layers(layer_indices.to_vec()),
194 captured: HashMap::new(),
195 }
196 }
197
198 /// Capture tensors whose graph names match exactly.
199 ///
200 /// Example names: `"result_norm"`, `"l_out-27"`.
201 #[must_use]
202 pub fn for_names(names: &[&str]) -> Self {
203 Self {
204 filter: CaptureFilter::Names(
205 names.iter().map(std::string::ToString::to_string).collect(),
206 ),
207 captured: HashMap::new(),
208 }
209 }
210
211 /// Capture every tensor whose name starts with `prefix`.
212 ///
213 /// Useful for families like `"attn_out-*"` or `"attn_norm-*"`.
214 #[must_use]
215 pub fn for_prefix(prefix: &str) -> Self {
216 Self {
217 filter: CaptureFilter::Prefix(prefix.to_string()),
218 captured: HashMap::new(),
219 }
220 }
221
222 /// Capture **all** graph nodes.
223 ///
224 /// Warning: memory use scales with model size and sequence length. Prefer
225 /// [`Self::for_layers`] or [`Self::for_names`] in production code.
226 #[must_use]
227 pub fn all() -> Self {
228 Self {
229 filter: CaptureFilter::All,
230 captured: HashMap::new(),
231 }
232 }
233
234 /// Drop captured tensors but keep the filter (safe to call before another decode).
235 pub fn clear(&mut self) {
236 self.captured.clear();
237 }
238
239 /// Lookup by full graph name (e.g. `"l_out-13"`).
240 #[must_use]
241 pub fn get(&self, name: &str) -> Option<&CapturedTensor> {
242 self.captured.get(name)
243 }
244
245 /// Lookup a layer output (`"l_out-{layer_idx}"`).
246 #[must_use]
247 pub fn get_layer(&self, layer_idx: usize) -> Option<&CapturedTensor> {
248 self.captured.get(&format!("l_out-{layer_idx}"))
249 }
250
251 /// Whether `"l_out-{layer_idx}"` was captured in the last decode.
252 #[must_use]
253 pub fn has_layer(&self, layer_idx: usize) -> bool {
254 self.captured.contains_key(&format!("l_out-{layer_idx}"))
255 }
256
257 /// Number of tensors stored from the most recent decode.
258 #[must_use]
259 pub fn len(&self) -> usize {
260 self.captured.len()
261 }
262
263 /// `true` when [`Self::len`] is zero.
264 #[must_use]
265 pub fn is_empty(&self) -> bool {
266 self.captured.is_empty()
267 }
268
269 /// Iterate `(name, tensor)` pairs from the last decode.
270 pub fn iter(&self) -> impl Iterator<Item = (&str, &CapturedTensor)> {
271 self.captured.iter().map(|(k, v)| (k.as_str(), v))
272 }
273
274 /// Sorted layer indices present among captured `"l_out-*"` tensors.
275 #[must_use]
276 pub fn captured_layers(&self) -> Vec<usize> {
277 let mut layers: Vec<usize> = self.captured.values().filter_map(|ct| ct.layer).collect();
278 layers.sort_unstable();
279 layers.dedup();
280 layers
281 }
282
283 fn matches(&self, name: &str) -> bool {
284 match &self.filter {
285 CaptureFilter::Layers(indices) => {
286 if let Some(suffix) = name.strip_prefix("l_out-") {
287 if let Ok(idx) = suffix.parse::<usize>() {
288 return indices.contains(&idx);
289 }
290 }
291 false
292 }
293 CaptureFilter::Names(names) => names.iter().any(|n| n == name),
294 CaptureFilter::Prefix(prefix) => name.starts_with(prefix.as_str()),
295 CaptureFilter::All => true,
296 }
297 }
298
299 fn store(&mut self, name: String, ne0: usize, ne1: usize, data: Vec<f32>) {
300 let layer = name
301 .strip_prefix("l_out-")
302 .and_then(|s| s.parse::<usize>().ok());
303
304 self.captured.insert(
305 name.clone(),
306 CapturedTensor {
307 name,
308 layer,
309 ne0,
310 ne1,
311 data,
312 },
313 );
314 }
315}
316
317/// `cb_eval` callback installed by [`LlamaContextParams::with_tensor_capture`](crate::LlamaContextParams::with_tensor_capture).
318///
319/// # Safety
320///
321/// `user_data` must point to a live [`TensorCapture`] for the context lifetime.
322pub(crate) unsafe extern "C" fn tensor_capture_callback(
323 t: *mut llama_cpp_sys_4::ggml_tensor,
324 ask: bool,
325 user_data: *mut std::ffi::c_void,
326) -> bool {
327 if t.is_null() || user_data.is_null() {
328 return false;
329 }
330
331 let name_bytes = &(*t).name;
332 let len = name_bytes
333 .iter()
334 .position(|&b| b == 0)
335 .unwrap_or(name_bytes.len());
336 let name = std::str::from_utf8_unchecked(std::slice::from_raw_parts(
337 name_bytes.as_ptr().cast::<u8>(),
338 len,
339 ));
340
341 let state = &mut *user_data.cast::<TensorCapture>();
342
343 if !state.matches(name) {
344 return false;
345 }
346
347 if ask {
348 return true;
349 }
350
351 let ne0 = usize::try_from((*t).ne[0]).expect("tensor ne[0] must be non-negative");
352 let ne1 = usize::try_from((*t).ne[1]).expect("tensor ne[1] must be non-negative");
353 let n_elements = ne0 * ne1;
354
355 let mut buf = vec![0f32; n_elements];
356 llama_cpp_sys_4::ggml_backend_tensor_get(
357 t,
358 buf.as_mut_ptr().cast::<std::ffi::c_void>(),
359 0,
360 n_elements * std::mem::size_of::<f32>(),
361 );
362
363 state.store(name.to_string(), ne0, ne1, buf);
364
365 true
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371
372 #[test]
373 fn test_for_layers_matching() {
374 let capture = TensorCapture::for_layers(&[13, 20, 27]);
375 assert!(capture.matches("l_out-13"));
376 assert!(capture.matches("l_out-20"));
377 assert!(capture.matches("l_out-27"));
378 assert!(!capture.matches("l_out-0"));
379 assert!(!capture.matches("l_out-14"));
380 assert!(!capture.matches("attn_norm-13"));
381 assert!(!capture.matches("result_norm"));
382 }
383
384 #[test]
385 fn test_for_names_matching() {
386 let capture = TensorCapture::for_names(&["result_norm", "l_out-27"]);
387 assert!(capture.matches("result_norm"));
388 assert!(capture.matches("l_out-27"));
389 assert!(!capture.matches("l_out-13"));
390 assert!(!capture.matches("result_output"));
391 }
392
393 #[test]
394 fn test_for_prefix_matching() {
395 let capture = TensorCapture::for_prefix("attn_out");
396 assert!(capture.matches("attn_out-0"));
397 assert!(capture.matches("attn_out-27"));
398 assert!(!capture.matches("attn_norm-0"));
399 assert!(!capture.matches("l_out-0"));
400 }
401
402 #[test]
403 fn test_all_matching() {
404 let capture = TensorCapture::all();
405 assert!(capture.matches("l_out-13"));
406 assert!(capture.matches("result_norm"));
407 assert!(capture.matches("anything"));
408 }
409
410 #[test]
411 fn test_store_and_get() {
412 let mut capture = TensorCapture::for_layers(&[13]);
413 let data = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
414 capture.store("l_out-13".to_string(), 3, 2, data.clone());
415
416 assert_eq!(capture.len(), 1);
417 assert!(!capture.is_empty());
418
419 let ct = capture.get("l_out-13").unwrap();
420 assert_eq!(ct.name, "l_out-13");
421 assert_eq!(ct.layer, Some(13));
422 assert_eq!(ct.n_embd(), 3);
423 assert_eq!(ct.n_tokens(), 2);
424 assert_eq!(ct.data, data);
425
426 let ct2 = capture.get_layer(13).unwrap();
427 assert_eq!(ct2.name, ct.name);
428 assert!(capture.has_layer(13));
429 assert!(!capture.has_layer(14));
430 }
431
432 #[test]
433 fn test_token_embedding() {
434 let mut capture = TensorCapture::for_layers(&[5]);
435 let data = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
436 capture.store("l_out-5".to_string(), 3, 2, data);
437
438 let ct = capture.get_layer(5).unwrap();
439 assert_eq!(ct.token_embedding(0), Some(&[1.0, 2.0, 3.0][..]));
440 assert_eq!(ct.token_embedding(1), Some(&[4.0, 5.0, 6.0][..]));
441 assert_eq!(ct.token_embedding(2), None);
442 }
443
444 #[test]
445 fn test_captured_layers() {
446 let mut capture = TensorCapture::for_layers(&[5, 10, 20]);
447 capture.store("l_out-10".to_string(), 2, 1, vec![0.0, 0.0]);
448 capture.store("l_out-5".to_string(), 2, 1, vec![0.0, 0.0]);
449 assert_eq!(capture.captured_layers(), vec![5, 10]);
450 }
451
452 #[test]
453 fn test_clear() {
454 let mut capture = TensorCapture::for_layers(&[5]);
455 capture.store("l_out-5".to_string(), 2, 1, vec![0.0, 0.0]);
456 assert_eq!(capture.len(), 1);
457 capture.clear();
458 assert_eq!(capture.len(), 0);
459 assert!(capture.is_empty());
460 }
461
462 #[test]
463 fn test_non_layer_tensor() {
464 let mut capture = TensorCapture::for_names(&["result_norm"]);
465 capture.store("result_norm".to_string(), 4, 3, vec![0.0; 12]);
466 let ct = capture.get("result_norm").unwrap();
467 assert_eq!(ct.layer, None);
468 assert_eq!(ct.n_embd(), 4);
469 assert_eq!(ct.n_tokens(), 3);
470 }
471}