1use 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#[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#[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
44pub fn tensor_id(t: &Tensor) -> String {
46 format!("{id:?}", id = t.id())
47}
48
49pub fn storage_id(t: &Tensor) -> String {
51 let (storage, _) = t.storage_and_layout();
52 format!("storage:{:p}", &*storage)
53}
54
55pub 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
64pub fn dtype_label(dtype: DType) -> String {
66 format!("{dtype:?}").to_ascii_lowercase()
67}
68
69pub 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 pub fn storage_id(&self) -> &str {
103 &self.storage_id
104 }
105
106 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
133pub fn inputs_dense_bytes(tensors: &[&Tensor]) -> u64 {
135 tensors.iter().map(|t| tensor_dense_bytes(t)).sum()
136}
137
138pub 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
159pub 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#[derive(Debug)]
174pub struct GradientCapturePlan {
175 root: String,
176 entries: Vec<(String, Var)>,
177 contract: GradientContract,
178}
179
180impl GradientCapturePlan {
181 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 pub fn contract(&self) -> &GradientContract {
213 &self.contract
214 }
215
216 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
241fn 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
253pub 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}