Skip to main content

candle_graph/instrument/
candle.rs

1//! Candle tensor helpers — PyTorch `record_shapes` / `profile_memory` ergonomics for probes.
2//!
3//! Enable with the `candle` feature: `candle-graph = { path = "..", features = ["candle"] }`.
4
5use anyhow::Context as _;
6use candle_core::backprop::GradStore;
7use candle_core::{DType, Device, DeviceLocation, Tensor, Var};
8
9use crate::capability::{ExpectedGradient, GradientContract, GradientFamilyContract};
10use crate::instrument::SpanId;
11use crate::instrument::{OpRecord, TensorRecord, TraceSession};
12use crate::phase::ExecutionStep;
13use crate::trace::memory::{category_for_step, dense_tensor_bytes};
14use crate::trace::{GradientState, MemoryCategory};
15
16/// Owned metadata captured from a Candle tensor (safe to store between probe calls).
17///
18/// Backend identity (`storage_id`, `tensor_id`) is read-only: it always comes
19/// from the observed tensor. Semantic naming goes through [`Self::with_label`].
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct CandleCapture {
22    storage_id: String,
23    tensor_id: String,
24    pub label: Option<String>,
25    pub shape: Vec<usize>,
26    pub dtype: String,
27    pub device: String,
28    pub tensor_bytes: u64,
29    pub requires_grad: bool,
30    pub category: MemoryCategory,
31}
32
33/// Owned op observation including output tensor metadata and input byte sum.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct CandleOpCapture {
36    pub op_name: String,
37    pub inputs: Vec<String>,
38    pub output: CandleCapture,
39    pub input_tensor_bytes: u64,
40    pub duration_ns: u64,
41    pub timestamp_ns: u64,
42}
43
44/// Stable string form of [`Tensor::id`] for trace dedup.
45pub fn tensor_id(t: &Tensor) -> String {
46    format!("{id:?}", id = t.id())
47}
48
49/// Process-local identity of the backend storage shared by aliased tensor views.
50pub fn storage_id(t: &Tensor) -> String {
51    let (storage, _) = t.storage_and_layout();
52    format!("storage:{:p}", &*storage)
53}
54
55/// Device label for JSONL (`cpu`, `cuda:0`, `metal:0`, …).
56pub fn device_label(device: &Device) -> String {
57    match device.location() {
58        DeviceLocation::Cpu => "cpu".into(),
59        DeviceLocation::Cuda { gpu_id } => format!("cuda:{gpu_id}"),
60        DeviceLocation::Metal { gpu_id } => format!("metal:{gpu_id}"),
61    }
62}
63
64/// Lowercase dtype label (`f32`, `bf16`, …).
65pub fn dtype_label(dtype: DType) -> String {
66    format!("{dtype:?}").to_ascii_lowercase()
67}
68
69/// Dense tensor footprint. This does not claim the size of shared backing storage.
70pub fn tensor_dense_bytes(t: &Tensor) -> u64 {
71    dense_tensor_bytes(t.dims(), &dtype_label(t.dtype()))
72        .expect("every Candle dtype has a known byte width")
73}
74
75impl CandleCapture {
76    pub fn from_tensor(t: &Tensor, step: Option<ExecutionStep>) -> Self {
77        let requires_grad = t.is_variable();
78        let category = if requires_grad {
79            MemoryCategory::Parameter
80        } else {
81            category_for_step(step, false)
82        };
83        Self {
84            storage_id: storage_id(t),
85            tensor_id: tensor_id(t),
86            label: None,
87            shape: t.dims().to_vec(),
88            dtype: dtype_label(t.dtype()),
89            device: device_label(t.device()),
90            tensor_bytes: tensor_dense_bytes(t),
91            requires_grad,
92            category,
93        }
94    }
95
96    pub fn with_label(mut self, label: impl Into<String>) -> Self {
97        self.label = Some(label.into());
98        self
99    }
100
101    /// Process-local identity of the backend storage observed at capture time.
102    pub fn storage_id(&self) -> &str {
103        &self.storage_id
104    }
105
106    /// Backend tensor identity observed at capture time.
107    pub fn tensor_id(&self) -> &str {
108        &self.tensor_id
109    }
110}
111
112impl CandleOpCapture {
113    pub fn new(
114        op_name: impl Into<String>,
115        inputs: Vec<String>,
116        output: &Tensor,
117        input_bytes: u64,
118        duration_ns: u64,
119        timestamp_ns: u64,
120        step: Option<ExecutionStep>,
121    ) -> Self {
122        Self {
123            op_name: op_name.into(),
124            inputs,
125            output: CandleCapture::from_tensor(output, step),
126            input_tensor_bytes: input_bytes,
127            duration_ns,
128            timestamp_ns,
129        }
130    }
131}
132
133/// Sum dense footprints for input tensors (PyTorch input-shape-style metadata).
134pub fn inputs_dense_bytes(tensors: &[&Tensor]) -> u64 {
135    tensors.iter().map(|t| tensor_dense_bytes(t)).sum()
136}
137
138/// Record a Candle tensor via [`TraceSession::record_tensor`].
139pub fn record_tensor(
140    session: &TraceSession,
141    span_id: SpanId,
142    cap: &CandleCapture,
143) -> anyhow::Result<()> {
144    session.record_tensor(
145        span_id,
146        TensorRecord {
147            tensor_id: cap.tensor_id(),
148            label: cap.label.as_deref(),
149            shape: &cap.shape,
150            dtype: &cap.dtype,
151            device: &cap.device,
152            requires_grad: cap.requires_grad,
153            dense_bytes: Some(cap.tensor_bytes),
154            category: cap.category,
155        },
156    )
157}
158
159/// Record a semantically labeled Candle tensor in one call.
160pub fn record_tensor_with_label(
161    session: &TraceSession,
162    span_id: SpanId,
163    label: impl Into<String>,
164    tensor: &Tensor,
165    step: Option<ExecutionStep>,
166) -> anyhow::Result<()> {
167    let cap = CandleCapture::from_tensor(tensor, step).with_label(label);
168    record_tensor(session, span_id, &cap)
169}
170
171/// Exact-coverage gradient capture: one validated manifest drives both the
172/// declared contract and the recorded event population.
173#[derive(Debug)]
174pub struct GradientCapturePlan {
175    root: String,
176    entries: Vec<(String, Var)>,
177    contract: GradientContract,
178}
179
180impl GradientCapturePlan {
181    /// Build a plan from named variables. Keys are sorted ascending and the
182    /// exact gradient contract is validated (non-empty manifest, unique keys,
183    /// family membership) with its SHA-256 digest bound at construction.
184    pub fn from_named_vars(
185        root: impl Into<String>,
186        vars: impl IntoIterator<Item = (String, Var)>,
187        assign_family: impl Fn(&str) -> String,
188        families: Vec<GradientFamilyContract>,
189    ) -> anyhow::Result<Self> {
190        let root = root.into();
191        let mut entries: Vec<(String, Var)> = vars.into_iter().collect();
192        entries.sort_by(|(a, _), (b, _)| a.cmp(b));
193        let expected = entries
194            .iter()
195            .map(|(key, _)| ExpectedGradient::new(root.clone(), key.clone(), assign_family(key)))
196            .collect();
197        let contract = GradientContract::new(expected, families)
198            .context("building exact gradient contract from named vars")?;
199        Ok(Self {
200            root,
201            entries,
202            contract,
203        })
204    }
205
206    pub fn root(&self) -> &str {
207        &self.root
208    }
209
210    /// Validated exact gradient contract. Clone it into the capture contract's
211    /// `gradient_contract` alongside `CoverageLevel::Complete`.
212    pub fn contract(&self) -> &GradientContract {
213        &self.contract
214    }
215
216    /// Record exactly one gradient event per manifest entry, in manifest order.
217    pub fn record(&self, session: &TraceSession, grads: &GradStore) -> anyhow::Result<()> {
218        for (key, var) in &self.entries {
219            let (state, norm) = match grads.get(var.as_tensor()) {
220                None => (GradientState::Missing, None),
221                Some(grad) => {
222                    let norm = gradient_l2_norm(grad)
223                        .with_context(|| format!("computing gradient norm for {key:?}"))?;
224                    if !norm.is_finite() {
225                        (GradientState::NonFinite, None)
226                    } else if norm == 0.0 {
227                        (GradientState::Zero, Some(0.0))
228                    } else {
229                        (GradientState::Present, Some(norm))
230                    }
231                }
232            };
233            session
234                .record_gradient(&self.root, key, state, norm)
235                .with_context(|| format!("recording gradient event for {key:?}"))?;
236        }
237        Ok(())
238    }
239}
240
241/// L2 norm computed on device with a single scalar readback.
242fn gradient_l2_norm(grad: &Tensor) -> anyhow::Result<f64> {
243    let norm = grad
244        .detach()
245        .to_dtype(DType::F32)?
246        .sqr()?
247        .sum_all()?
248        .sqrt()?
249        .to_scalar::<f32>()?;
250    Ok(f64::from(norm))
251}
252
253/// Record a timed operation and its category-linked output tensor from a [`CandleOpCapture`].
254pub fn record_op(
255    session: &TraceSession,
256    span_id: SpanId,
257    cap: &CandleOpCapture,
258) -> anyhow::Result<()> {
259    session.record_op(
260        span_id,
261        OpRecord {
262            op_name: &cap.op_name,
263            inputs: &cap.inputs,
264            output: Some(cap.output.tensor_id()),
265            shape: &cap.output.shape,
266            dtype: &cap.output.dtype,
267            device: &cap.output.device,
268            duration_ns: cap.duration_ns,
269            timestamp_ns: cap.timestamp_ns,
270            output_dense_bytes: Some(cap.output.tensor_bytes),
271            input_dense_bytes: cap.input_tensor_bytes,
272        },
273    )?;
274    record_tensor(session, span_id, &cap.output)
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use candle_core::Tensor;
281
282    #[test]
283    fn dense_tensor_footprint_matches_shape_dtype() {
284        let t = Tensor::zeros((4, 8), DType::F32, &Device::Cpu).unwrap();
285        assert_eq!(tensor_dense_bytes(&t), 4 * 8 * 4);
286    }
287
288    #[test]
289    fn capture_from_tensor() {
290        let t = Tensor::zeros((2, 3), DType::F32, &Device::Cpu).unwrap();
291        let cap = CandleCapture::from_tensor(&t, Some(ExecutionStep::Forward));
292        assert_eq!(cap.shape, vec![2, 3]);
293        assert_eq!(cap.category, MemoryCategory::Activation);
294    }
295
296    #[test]
297    fn record_op_category_links_its_output_tensor() {
298        use crate::instrument::{ProfileRun, SpanKind};
299        use crate::trace::parse_trace;
300
301        let output = Tensor::zeros((2, 3), DType::F32, &Device::Cpu).unwrap();
302        let capture = CandleOpCapture::new(
303            "relu",
304            Vec::new(),
305            &output,
306            0,
307            1,
308            1,
309            Some(ExecutionStep::Forward),
310        );
311        let path = temp_trace("activation-output");
312        let session =
313            TraceSession::open(&path, ProfileRun::inference("model::forward", 1, "cpu")).unwrap();
314        let op_id = {
315            let _measured = session.begin_measurement("model/forward");
316            let op = session.begin_span("relu", SpanKind::Op);
317            let op_id = op.id();
318            record_op(&session, op_id, &capture).unwrap();
319            op_id
320        };
321        session.finish().unwrap();
322
323        let document = parse_trace(&path).unwrap();
324        assert_eq!(document.ops.len(), 1);
325        assert_eq!(document.tensors.len(), 1);
326        assert_eq!(document.tensors[0].span_id, format!("s{}", op_id.raw()));
327        assert_eq!(document.tensors[0].category, MemoryCategory::Activation);
328        assert_eq!(
329            document.ops[0].output.as_deref(),
330            Some(document.tensors[0].tensor_id.as_str())
331        );
332        std::fs::remove_file(path).unwrap();
333    }
334
335    fn temp_trace(name: &str) -> std::path::PathBuf {
336        std::env::temp_dir().join(format!(
337            "candle-graph-candle-{}-{}-{name}",
338            std::process::id(),
339            std::time::SystemTime::now()
340                .duration_since(std::time::UNIX_EPOCH)
341                .unwrap()
342                .as_nanos()
343        ))
344    }
345
346    fn zero_var() -> Var {
347        Var::zeros((2,), DType::F32, &Device::Cpu).unwrap()
348    }
349
350    fn params_family() -> Vec<GradientFamilyContract> {
351        vec![GradientFamilyContract::data_conditional("params", 1)]
352    }
353
354    #[test]
355    fn gradient_plan_sorts_keys_into_manifest_order() {
356        let plan = GradientCapturePlan::from_named_vars(
357            "varmap",
358            vec![
359                ("decoder.weight".to_string(), zero_var()),
360                ("encoder.bias".to_string(), zero_var()),
361                ("encoder.weight".to_string(), zero_var()),
362            ],
363            |_| "params".to_string(),
364            params_family(),
365        )
366        .unwrap();
367
368        assert_eq!(plan.root(), "varmap");
369        let keys: Vec<&str> = plan
370            .contract()
371            .expected
372            .iter()
373            .map(|expected| expected.key.as_str())
374            .collect();
375        assert_eq!(keys, ["decoder.weight", "encoder.bias", "encoder.weight"]);
376        assert!(plan
377            .contract()
378            .expected
379            .iter()
380            .all(|expected| expected.root == "varmap" && expected.family == "params"));
381    }
382
383    #[test]
384    fn gradient_plan_rejects_duplicate_keys_and_empty_manifests() {
385        let duplicate = GradientCapturePlan::from_named_vars(
386            "varmap",
387            vec![
388                ("encoder.weight".to_string(), zero_var()),
389                ("encoder.weight".to_string(), zero_var()),
390            ],
391            |_| "params".to_string(),
392            params_family(),
393        );
394        let message = format!("{:#}", duplicate.unwrap_err());
395        assert!(message.contains("more than once"), "got: {message}");
396
397        let empty = GradientCapturePlan::from_named_vars(
398            "varmap",
399            Vec::<(String, Var)>::new(),
400            |_| "params".to_string(),
401            params_family(),
402        );
403        let message = format!("{:#}", empty.unwrap_err());
404        assert!(message.contains("must not be empty"), "got: {message}");
405    }
406
407    #[test]
408    fn gradient_plan_records_exact_manifest_population() {
409        use crate::capability::{CaptureContract, CoverageLevel};
410        use crate::instrument::ProfileRun;
411        use crate::trace::parse_trace;
412
413        let device = Device::Cpu;
414        let var_a = Var::new(&[1.0f32, 2.0, 3.0], &device).unwrap();
415        let var_b = Var::new(&[4.0f32, 5.0], &device).unwrap();
416        let plan = GradientCapturePlan::from_named_vars(
417            "varmap",
418            vec![
419                ("used.weight".to_string(), var_a.clone()),
420                ("frozen.weight".to_string(), var_b),
421            ],
422            |_| "params".to_string(),
423            params_family(),
424        )
425        .unwrap();
426
427        let loss = (var_a.as_tensor() * 2.0).unwrap().sum_all().unwrap();
428        let grads = loss.backward().unwrap();
429
430        let path = temp_trace("gradient-plan");
431        let run =
432            ProfileRun::training("train::update", 1, "cpu").capture_contract(CaptureContract {
433                gradients: CoverageLevel::Complete,
434                gradient_contract: Some(plan.contract().clone()),
435                ..CaptureContract::default()
436            });
437        let session = TraceSession::open(&path, run).unwrap();
438        plan.record(&session, &grads).unwrap();
439        session.finish().unwrap();
440
441        let doc = parse_trace(&path).unwrap();
442        let manifest_keys: Vec<&str> = plan
443            .contract()
444            .expected
445            .iter()
446            .map(|expected| expected.key.as_str())
447            .collect();
448        let recorded_keys: Vec<&str> = doc
449            .gradients
450            .iter()
451            .map(|event| event.key.as_str())
452            .collect();
453        assert_eq!(recorded_keys, manifest_keys);
454        assert_eq!(recorded_keys, ["frozen.weight", "used.weight"]);
455
456        let frozen = &doc.gradients[0];
457        assert_eq!(frozen.state, GradientState::Missing);
458        assert_eq!(frozen.norm, None);
459
460        let used = &doc.gradients[1];
461        assert_eq!(used.state, GradientState::Present);
462        assert!(used.norm.is_some_and(|norm| norm > 0.0));
463    }
464}