1use laddu_compile::{
2 CompiledModel, NormalizationDiagnostics, NormalizationStrategy, ReductionPlan,
3};
4use laddu_data::{
5 data::{CacheStorage, Dataset},
6 io::ReadPlan,
7};
8use laddu_expr::parameters::{ParamError, ParamLayout, ParamProjection, ParamValues};
9use laddu_memory::{MemoryFitRequest, MemoryFootprint};
10use num::complex::{Complex32, Complex64};
11use std::sync::{
12 Arc,
13 atomic::{AtomicBool, Ordering},
14};
15
16use crate::{
17 CpuBackend, CpuPlan, Execution, MemoryLease, NormalizationMode, PreparedDataset,
18 PreparedDatasetStats, PreparedModel, RuntimeError, RuntimeResult,
19};
20
21const AUTO_BREAK_EVEN_EVALUATIONS: usize = 16;
22
23#[derive(Clone, Debug, PartialEq, Eq)]
25pub struct PreparedNormalizationDiagnostics {
26 strategy: NormalizationStrategy,
27 compiler: NormalizationDiagnostics,
28 retained_bytes: usize,
29 preparation_passes: usize,
30 cache_hit: bool,
31 tag_projection_reused_parent: bool,
32}
33
34impl PreparedNormalizationDiagnostics {
35 pub fn strategy(&self) -> NormalizationStrategy {
37 self.strategy
38 }
39
40 pub fn compiler(&self) -> &NormalizationDiagnostics {
42 &self.compiler
43 }
44
45 pub fn retained_bytes(&self) -> usize {
47 self.retained_bytes
48 }
49
50 pub fn preparation_passes(&self) -> usize {
52 self.preparation_passes
53 }
54
55 pub fn cache_hit(&self) -> bool {
57 self.cache_hit
58 }
59
60 pub fn tag_projection_reused_parent(&self) -> bool {
62 self.tag_projection_reused_parent
63 }
64
65 #[doc(hidden)]
67 pub fn general(compiler: NormalizationDiagnostics) -> Self {
68 Self {
69 strategy: NormalizationStrategy::General,
70 compiler,
71 retained_bytes: 0,
72 preparation_passes: 1,
73 cache_hit: false,
74 tag_projection_reused_parent: false,
75 }
76 }
77}
78
79#[derive(Clone, Debug)]
80struct GeneralResidual {
81 plan: PreparedModel,
82 dataset: PreparedDataset,
83 parameters: ParamProjection,
84}
85
86#[derive(Debug)]
87enum StoredStatistics {
88 F32(Vec<Complex32>),
89 F64(Vec<Complex64>),
90}
91
92impl StoredStatistics {
93 fn from_f64(values: Vec<Complex64>, precision: crate::Precision) -> Self {
94 if precision == crate::Precision::F32 {
95 Self::F32(
96 values
97 .into_iter()
98 .map(|value| Complex32::new(value.re as f32, value.im as f32))
99 .collect(),
100 )
101 } else {
102 Self::F64(values)
103 }
104 }
105
106 fn resident_bytes(&self) -> usize {
107 match self {
108 Self::F32(values) => values.capacity() * std::mem::size_of::<Complex32>(),
109 Self::F64(values) => values.capacity() * std::mem::size_of::<Complex64>(),
110 }
111 }
112
113 fn evaluator_values(&self) -> Vec<Complex64> {
114 match self {
115 Self::F32(values) => values
116 .iter()
117 .map(|value| Complex64::new(value.re as f64, value.im as f64))
118 .collect(),
119 Self::F64(values) => values.clone(),
120 }
121 }
122}
123
124fn normalization_projection(
125 child: &ParamLayout,
126 parent: &ParamLayout,
127) -> RuntimeResult<ParamProjection> {
128 child.projection_from(parent).map_err(|error| match error {
129 ParamError::UnknownName(name) => RuntimeError::Data(format!(
130 "normalization parameter `{name}` is absent from the source model"
131 )),
132 ParamError::ParameterConflict { name, .. } => RuntimeError::Data(format!(
133 "normalization parameter `{name}` is unexpectedly fixed in the source model"
134 )),
135 error => RuntimeError::Parameter(error.to_string()),
136 })
137}
138
139fn project_normalization(
140 projection: &ParamProjection,
141 params: &ParamValues,
142) -> RuntimeResult<ParamValues> {
143 projection.project(params).map_err(|error| match error {
144 ParamError::UnknownName(name) => RuntimeError::Data(format!(
145 "normalization parameter `{name}` is absent from supplied values"
146 )),
147 error => RuntimeError::Parameter(error.to_string()),
148 })
149}
150
151#[derive(Debug)]
153pub struct PreparedNormalization {
154 evaluator: CpuPlan,
155 evaluator_parameters: ParamProjection,
156 statistics: StoredStatistics,
157 residual: Option<GeneralResidual>,
158 verification: Option<GeneralResidual>,
159 stats: PreparedDatasetStats,
160 diagnostics: PreparedNormalizationDiagnostics,
161 cache_reused: AtomicBool,
162 _memory_lease: MemoryLease,
163}
164
165#[derive(Debug)]
166struct NormalizationEvaluation {
167 value: f64,
168 gradient: Option<Vec<f64>>,
169}
170
171impl PreparedNormalization {
172 pub fn prepare(
179 model: &CompiledModel,
180 general_plan: &PreparedModel,
181 dataset: &Dataset,
182 execution: &Execution,
183 ) -> RuntimeResult<Option<Arc<Self>>> {
184 if execution.normalization_mode() == NormalizationMode::General
185 || model.normalization_diagnostics().strategy() == NormalizationStrategy::General
186 || (execution.normalization_mode() == NormalizationMode::Auto
187 && !model.normalization_plan().proven_nonnegative())
188 {
189 return Ok(None);
190 }
191
192 let key = (
193 model.optimized_digest(),
194 dataset.identity(),
195 execution.normalization_mode(),
196 );
197 let mut cache = execution
198 .normalization_cache()
199 .lock()
200 .unwrap_or_else(|error| error.into_inner());
201 cache.retain(|_, prepared| prepared.strong_count() > 0);
202 if let Some(prepared) = cache.get(&key).and_then(std::sync::Weak::upgrade) {
203 prepared.cache_reused.store(true, Ordering::Relaxed);
204 return Ok(Some(prepared));
205 }
206 let Some(prepared) = Self::prepare_uncached(model, general_plan, dataset, execution)?
207 else {
208 return Ok(None);
209 };
210 let prepared = Arc::new(prepared);
211 cache.insert(key, Arc::downgrade(&prepared));
212 Ok(Some(prepared))
213 }
214
215 fn prepare_uncached(
216 model: &CompiledModel,
217 general_plan: &PreparedModel,
218 dataset: &Dataset,
219 execution: &Execution,
220 ) -> RuntimeResult<Option<Self>> {
221 let basis_models = model
222 .normalization_plan()
223 .basis_models()
224 .map_err(|error| RuntimeError::Data(error.to_string()))?;
225 let basis_work = basis_models
226 .iter()
227 .map(|basis| basis.graph().nodes().len())
228 .sum::<usize>();
229 let general_work = model.graph().nodes().len().max(1);
230 if execution.normalization_mode() == NormalizationMode::Auto
231 && basis_work > general_work.saturating_mul(AUTO_BREAK_EVEN_EVALUATIONS)
232 {
233 return Ok(None);
234 }
235
236 let statistic_bytes = if execution.precision() == crate::Precision::F32 {
237 std::mem::size_of::<Complex32>()
238 } else {
239 std::mem::size_of::<Complex64>()
240 };
241 let retained_bytes = basis_models.len().saturating_mul(statistic_bytes);
242 let memory_lease = match execution
243 .host_memory()
244 .reserve(u64::try_from(retained_bytes).unwrap_or(u64::MAX))
245 {
246 Ok(lease) => lease,
247 Err(_) if execution.normalization_mode() == NormalizationMode::Auto => return Ok(None),
248 Err(error) => return Err(error.into()),
249 };
250 let basis_plans = basis_models
251 .iter()
252 .map(|basis| {
253 CpuBackend.prepare_shared_with_autodiff_mode(basis, execution.autodiff_mode())
254 })
255 .collect::<RuntimeResult<Vec<_>>>()?;
256 let basis_params = basis_models
257 .iter()
258 .map(|basis| basis.params().default_values())
259 .collect::<Vec<_>>();
260 let (statistics, stats) =
261 accumulate_statistics(&basis_plans, &basis_params, dataset, execution)?;
262 let statistics = StoredStatistics::from_f64(statistics, execution.precision());
263 let evaluator_statistics = statistics.evaluator_values();
264 let evaluator_model = model
265 .normalization_plan()
266 .evaluator_model(&evaluator_statistics)
267 .map_err(|error| RuntimeError::Data(error.to_string()))?;
268 let evaluator = CpuBackend
272 .prepare_with_autodiff_mode(&evaluator_model, execution.autodiff_mode())
273 .map_err(|error| RuntimeError::Data(error.to_string()))?;
274 let evaluator_parameters =
275 normalization_projection(evaluator_model.params(), model.params())?;
276
277 let residual_model = model
278 .normalization_plan()
279 .residual_model()
280 .map_err(|error| RuntimeError::Data(error.to_string()))?;
281 let residual = if let Some(residual_model) = residual_model {
282 let parameters = normalization_projection(residual_model.params(), model.params())?;
283 let plan = PreparedModel::prepare(&residual_model, execution)?;
284 let dataset = plan.prepare_dataset(execution, dataset)?;
285 Some(GeneralResidual {
286 plan,
287 dataset,
288 parameters,
289 })
290 } else {
291 None
292 };
293 let verification = if execution.normalization_mode() == NormalizationMode::Verify {
294 Some(GeneralResidual {
295 plan: general_plan.clone(),
296 dataset: general_plan.prepare_dataset(execution, dataset)?,
297 parameters: normalization_projection(model.params(), model.params())?,
298 })
299 } else {
300 None
301 };
302 let preparation_passes =
303 1 + usize::from(residual.is_some()) + usize::from(verification.is_some());
304 Ok(Some(Self {
305 evaluator,
306 evaluator_parameters,
307 statistics,
308 residual,
309 verification,
310 stats,
311 diagnostics: PreparedNormalizationDiagnostics {
312 strategy: model.normalization_diagnostics().strategy(),
313 compiler: model.normalization_diagnostics().clone(),
314 retained_bytes,
315 preparation_passes,
316 cache_hit: false,
317 tag_projection_reused_parent: false,
318 },
319 cache_reused: AtomicBool::new(false),
320 _memory_lease: memory_lease,
321 }))
322 }
323
324 pub fn stats(&self) -> &PreparedDatasetStats {
326 &self.stats
327 }
328
329 pub fn diagnostics(&self) -> PreparedNormalizationDiagnostics {
331 let mut diagnostics = self.diagnostics.clone();
332 diagnostics.cache_hit = self.cache_reused.load(Ordering::Relaxed);
333 diagnostics
334 }
335
336 pub fn resident_bytes(&self) -> usize {
338 self.statistics.resident_bytes()
339 }
340
341 pub fn value(&self, params: &ParamValues, execution: &Execution) -> RuntimeResult<f64> {
348 Ok(self.evaluate_composed(params, execution, false)?.value)
349 }
350
351 pub fn value_gradient(
358 &self,
359 params: &ParamValues,
360 execution: &Execution,
361 ) -> RuntimeResult<(f64, Vec<f64>)> {
362 let evaluation = self.evaluate_composed(params, execution, true)?;
363 Ok((
364 evaluation.value,
365 evaluation.gradient.ok_or_else(|| {
366 RuntimeError::Data("normalization gradient composition produced no gradient".into())
367 })?,
368 ))
369 }
370
371 fn evaluate_composed(
372 &self,
373 params: &ParamValues,
374 execution: &Execution,
375 with_gradient: bool,
376 ) -> RuntimeResult<NormalizationEvaluation> {
377 let evaluator_params = project_normalization(&self.evaluator_parameters, params)?;
378 let (mut value, mut gradient) = if with_gradient {
379 let evaluation = self.evaluator.evaluate_with_gradient(&evaluator_params)?;
380 let mut gradient = vec![0.0; params.layout().n_free()];
381 let evaluator_gradient = evaluation
382 .gradient()
383 .iter()
384 .map(|value| value.re)
385 .collect::<Vec<_>>();
386 self.evaluator_parameters
387 .scatter_add(&evaluator_gradient, &mut gradient)
388 .map_err(|_| incompatible_gradient_layout())?;
389 (evaluation.value().re, Some(gradient))
390 } else {
391 (self.evaluator.evaluate(&evaluator_params)?.re, None)
392 };
393 if let Some(residual) = &self.residual {
394 let residual_params = project_normalization(&residual.parameters, params)?;
395 if let Some(gradient) = &mut gradient {
396 let residual_evaluation = residual.plan.reduce_with_gradient(
397 execution,
398 &residual_params,
399 &residual.dataset,
400 ReductionPlan::weighted_real(),
401 )?;
402 value += residual_evaluation.value();
403 residual
404 .parameters
405 .scatter_add(residual_evaluation.gradient(), gradient)
406 .map_err(|_| incompatible_gradient_layout())?;
407 } else {
408 value += residual.plan.reduce(
409 execution,
410 &residual_params,
411 &residual.dataset,
412 ReductionPlan::weighted_real(),
413 )?;
414 }
415 }
416 if let Some(general) = &self.verification {
417 let general_params = project_normalization(&general.parameters, params)?;
418 if let Some(gradient) = &gradient {
419 let expected = general.plan.reduce_with_gradient(
420 execution,
421 &general_params,
422 &general.dataset,
423 ReductionPlan::weighted_real(),
424 )?;
425 verify_close("normalization value", value, expected.value(), execution)?;
426 for (index, (actual, expected)) in
427 gradient.iter().zip(expected.gradient()).enumerate()
428 {
429 verify_close(
430 &format!("normalization gradient[{index}]"),
431 *actual,
432 *expected,
433 execution,
434 )?;
435 }
436 } else {
437 let expected = general.plan.reduce(
438 execution,
439 &general_params,
440 &general.dataset,
441 ReductionPlan::weighted_real(),
442 )?;
443 verify_close("normalization value", value, expected, execution)?;
444 }
445 }
446 Ok(NormalizationEvaluation { value, gradient })
447 }
448}
449
450fn incompatible_gradient_layout() -> RuntimeError {
451 RuntimeError::Data("normalization gradient has an incompatible parameter layout".into())
452}
453
454fn accumulate_statistics(
455 plans: &[Arc<CpuPlan>],
456 params: &[ParamValues],
457 dataset: &Dataset,
458 execution: &Execution,
459) -> RuntimeResult<(Vec<Complex64>, PreparedDatasetStats)> {
460 let mut sums = vec![Complex64::ZERO; plans.len()];
461 let mut corrections = vec![Complex64::ZERO; plans.len()];
462 let mut read_plan: ReadPlan = execution.read_plan(dataset.read_plan());
463 let local_limit = dataset
464 .num_events()
465 .map_err(|error| RuntimeError::Data(error.to_string()))?
466 .and_then(|events| usize::try_from(events).ok())
467 .unwrap_or(usize::MAX);
468 let statistic_bytes = plans.len().saturating_mul(std::mem::size_of::<Complex64>());
469 let decision = MemoryFitRequest {
470 label: "normalization statistics".into(),
471 footprint: MemoryFootprint::from_usize(statistic_bytes, statistic_bytes),
472 available_bytes: execution.host_memory().remaining(),
473 event_limit: local_limit,
474 strategy: "single-pass sufficient statistics".into(),
475 }
476 .evaluate()?;
477 read_plan.chunk_size = Some(
478 read_plan
479 .chunk_size
480 .map_or(decision.chunk_events, |manual| {
481 manual.min(decision.chunk_events)
482 })
483 .max(1),
484 );
485 execution.record_memory_decision(decision);
486 let local = (|| {
487 let mut events = 0usize;
488 let mut batches = 0usize;
489 let mut weight_sum = 0.0;
490 let mut weight_correction = 0.0;
491 for batch in dataset
492 .stream_with_plan(read_plan)
493 .map_err(|error| RuntimeError::Data(error.to_string()))?
494 {
495 let batch = batch.map_err(|error| RuntimeError::Data(error.to_string()))?;
496 events += batch.len();
497 batches += 1;
498 for row in 0..batch.len() {
499 let weight = batch.weights_at(row);
500 let corrected = weight - weight_correction;
501 let next = weight_sum + corrected;
502 weight_correction = (next - weight_sum) - corrected;
503 weight_sum = next;
504 }
505 for (index, (plan, params)) in plans.iter().zip(params).enumerate() {
506 for (row, value) in plan.evaluate_batch(params, &batch)?.into_iter().enumerate() {
507 let value = value * batch.weights_at(row);
508 let corrected = value - corrections[index];
509 let next = sums[index] + corrected;
510 corrections[index] = (next - sums[index]) - corrected;
511 sums[index] = next;
512 }
513 }
514 }
515 Ok::<_, RuntimeError>((events, batches, weight_sum))
516 })();
517 if !execution.all_succeeded(local.is_ok()) {
518 return local.and(Err(RuntimeError::DistributedPeerFailure));
519 }
520 let (events, batches, weight_sum) = local?;
521 for sum in &mut sums {
522 sum.re = execution.sum_f64(sum.re);
523 sum.im = execution.sum_f64(sum.im);
524 }
525 let stats = PreparedDatasetStats::new(
526 events,
527 execution.sum_usize(events),
528 batches,
529 execution.sum_f64(weight_sum),
530 sums.len() * std::mem::size_of::<Complex64>(),
531 CacheStorage::Resident,
532 );
533 Ok((sums, stats))
534}
535
536fn verify_close(
537 label: &str,
538 actual: f64,
539 expected: f64,
540 execution: &Execution,
541) -> RuntimeResult<()> {
542 let tolerance = match execution.precision() {
543 crate::Precision::F32 => 5.0e-4,
544 crate::Precision::Auto | crate::Precision::F64 => 1.0e-10,
545 } * expected.abs().max(1.0);
546 if (actual - expected).abs() <= tolerance {
547 Ok(())
548 } else {
549 Err(RuntimeError::Data(format!(
550 "{label} verification failed: compiler-native={actual}, general={expected}, tolerance={tolerance}"
551 )))
552 }
553}